1use std::collections::HashMap;
6
7#[derive(Debug, Clone, Copy)]
10pub struct SimpleDateTime {
11 timestamp: i64, }
13
14impl SimpleDateTime {
15 pub fn from_timestamp(ts: i64) -> Self {
17 Self { timestamp: ts }
18 }
19
20 pub fn format_imf_fixdate(&self) -> String {
23 const DAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
25 const MONTHS: [&str; 12] = [
27 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
28 ];
29
30 let total_days = self.timestamp / 86400;
33
34 let days_since_epoch = total_days + 719528; let mut year = 1970i64;
40 let mut remaining_days = days_since_epoch;
41
42 while remaining_days >= 365 {
43 let days_in_year = if is_leap_year(year) { 366 } else { 365 };
44 if remaining_days >= days_in_year {
45 remaining_days -= days_in_year;
46 year += 1;
47 } else {
48 break;
49 }
50 }
51
52 const DAYS_IN_MONTH: [u64; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
54 let mut month = 0usize;
55 while month < 12 {
56 let dim = if month == 1 && is_leap_year(year) {
57 29
58 } else {
59 DAYS_IN_MONTH[month]
60 };
61 if remaining_days >= dim as i64 {
62 remaining_days -= dim as i64;
63 month += 1;
64 } else {
65 break;
66 }
67 }
68
69 let day = remaining_days + 1; let secs_in_day = ((self.timestamp % 86400) + 86400) % 86400;
73 let hour = secs_in_day / 3600;
74 let minute = (secs_in_day % 3600) / 60;
75 let second = secs_in_day % 60;
76
77 let q = day;
80 let m = if month < 2 {
81 (month + 12) as i64
82 } else {
83 (month + 3) as i64
84 };
85 let y = if month < 2 { year - 1 } else { year };
86 let h = (q + (13 * (m + 1)) / 5 + y + y / 4 - y / 100 + y / 400) % 7;
87 let weekday_index = (h + 6) % 7; format!(
90 "{}, {:02} {} {:04} {:02}:{:02}:{:02} GMT",
91 DAYS[weekday_index as usize], day, MONTHS[month], year, hour, minute, second
92 )
93 }
94
95 pub fn parse_rfc2822(date_str: &str) -> Option<Self> {
98 let parts: Vec<&str> = date_str.split_whitespace().collect();
103 if parts.len() < 5 {
104 return None;
105 }
106
107 let (day_str, mon_str, year_str);
109
110 if parts[1].contains('-') {
111 let date_parts: Vec<&str> = parts[1].split('-').collect();
113 if date_parts.len() != 3 {
114 return None;
115 }
116 day_str = date_parts[0];
117 mon_str = date_parts[1];
118 year_str = date_parts[2];
119 } else {
120 day_str = parts[1];
122 mon_str = parts[2];
123 year_str = parts[3];
124 }
125
126 let day: u32 = day_str.parse().ok()?;
127 let year: i64 = if year_str.len() == 2 {
128 let yy: i32 = year_str.parse().ok()?;
130 if yy < 70 {
131 (2000 + yy) as i64
132 } else {
133 (1900 + yy) as i64
134 }
135 } else {
136 year_str.parse().ok()?
137 };
138
139 const MONTH_NAMES: [&str; 12] = [
141 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
142 ];
143 let month = MONTH_NAMES
144 .iter()
145 .position(|&m| m.eq_ignore_ascii_case(mon_str))? as u32
146 + 1;
147
148 let time_parts: Vec<&str> = parts[4].split(':').collect();
150 if time_parts.len() != 3 {
151 return None;
152 }
153 let hour: u32 = time_parts[0].parse().ok()?;
154 let minute: u32 = time_parts[1].parse().ok()?;
155 let second: u32 = time_parts[2].parse().ok()?;
156
157 let timestamp = datetime_to_timestamp(year, month, day, hour, minute, second);
159
160 Some(Self { timestamp })
161 }
162}
163
164fn is_leap_year(year: i64) -> bool {
166 (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
167}
168
169fn datetime_to_timestamp(
172 year: i64,
173 month: u32,
174 day: u32,
175 hour: u32,
176 minute: u32,
177 second: u32,
178) -> i64 {
179 const DAYS_IN_MONTH: [u32; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
181
182 let mut total_days: i64 = 0;
184
185 for y in 1970..year {
187 total_days += if is_leap_year(y) { 366 } else { 365 };
188 }
189
190 for m in 1..month {
192 total_days += if m == 2 && is_leap_year(year) {
193 29
194 } else {
195 DAYS_IN_MONTH[(m - 1) as usize]
196 } as i64;
197 }
198
199 total_days += (day - 1) as i64;
201
202 total_days * 86400 + hour as i64 * 3600 + minute as i64 * 60 + second as i64
204}
205
206pub struct ConditionalRequest {
208 pub last_modified: Option<SimpleDateTime>,
209 pub etag: Option<String>,
210 pub content_length: Option<u64>,
211 pub not_modified: bool,
213}
214
215impl ConditionalRequest {
216 pub fn new() -> Self {
218 Self {
219 last_modified: None,
220 etag: None,
221 content_length: None,
222 not_modified: false,
223 }
224 }
225}
226
227impl Default for ConditionalRequest {
228 fn default() -> Self {
229 Self::new()
230 }
231}
232
233impl ConditionalRequest {
234 pub fn to_headers(&self) -> Vec<(String, String)> {
237 let mut headers = Vec::new();
238
239 if let Some(ref etag) = self.etag {
240 headers.push(("If-None-Match".into(), etag.clone()));
241 headers.push(("If-Match".into(), etag.clone()));
242 }
243
244 if let Some(ref lm) = self.last_modified {
245 headers.push(("If-Modified-Since".into(), lm.format_imf_fixdate()));
246 headers.push(("If-Unmodified-Since".into(), lm.format_imf_fixdate()));
247 }
248
249 if let Some(len) = self.content_length {
250 headers.push(("Range".into(), format!("bytes={}-", len)));
251 }
252
253 headers
254 }
255
256 pub fn update_from_response(&mut self, status: u16, headers: &[(String, String)]) {
258 for (name, value) in headers {
259 match name.to_lowercase().as_str() {
260 "last-modified" => {
261 if let Some(dt) = SimpleDateTime::parse_rfc2822(value) {
262 self.last_modified = Some(dt);
263 }
264 }
265 "etag" => {
266 self.etag = Some(value.trim_matches('"').to_string());
267 }
268 "content-length" => {
269 if let Ok(len) = value.parse::<u64>() {
270 self.content_length = Some(len);
271 }
272 }
273 _ => {}
274 }
275 }
276
277 match status {
279 304 => {
280 self.not_modified = true;
282 }
283 206 => {
284 self.not_modified = false;
286 }
287 416 => {
288 self.content_length = None;
290 self.not_modified = false;
291 }
292 _ => {
293 self.not_modified = false;
294 }
295 }
296 }
297
298 pub fn should_skip(&self) -> bool {
300 self.not_modified
301 }
302
303 pub fn can_resume(&self, local_file_size: u64) -> bool {
305 self.content_length.is_some_and(|len| local_file_size < len)
306 }
307
308 pub fn needs_full_redownload(&self) -> bool {
310 self.content_length.is_none()
311 }
312}
313
314pub struct SmartResumeManager {
316 entries: HashMap<String, ConditionalRequest>, }
318
319impl SmartResumeManager {
320 pub fn new() -> Self {
322 Self {
323 entries: HashMap::new(),
324 }
325 }
326
327 pub fn get_or_create(&mut self, key: &str) -> &mut ConditionalRequest {
329 self.entries.entry(key.to_string()).or_default()
330 }
331
332 pub fn mark_unchanged(&mut self, key: &str) {
334 if let Some(entry) = self.entries.get_mut(key) {
335 entry.not_modified = true;
336 }
337 }
338
339 pub fn is_all_unchanged(&self, keys: &[&str]) -> bool {
341 keys.iter()
342 .all(|key| self.entries.get(*key).is_some_and(|e| e.not_modified))
343 }
344}
345
346impl Default for SmartResumeManager {
347 fn default() -> Self {
348 Self::new()
349 }
350}
351
352#[derive(Debug, Clone, PartialEq)]
354pub enum ResumeAction {
355 Continue, SkipUnchanged, RedownloadFull, RetryLater, }
360
361pub fn handle_resume_status(status: u16, _cond: &ConditionalRequest) -> ResumeAction {
364 match status {
365 200..=299 => ResumeAction::Continue,
366 304 => ResumeAction::SkipUnchanged,
367 416 => ResumeAction::RedownloadFull,
368 500..=599 => ResumeAction::RetryLater,
369 _ => ResumeAction::Continue, }
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376
377 #[test]
378 fn test_conditional_headers_etag() {
379 let mut cond = ConditionalRequest::new();
380 cond.etag = Some("\"abc123\"".to_string());
381
382 let headers = cond.to_headers();
383
384 assert!(
386 headers
387 .iter()
388 .any(|(k, v)| k == "If-None-Match" && v == "\"abc123\"")
389 );
390 assert!(
391 headers
392 .iter()
393 .any(|(k, v)| k == "If-Match" && v == "\"abc123\"")
394 );
395
396 cond.update_from_response(
398 200,
399 &[("ETag".to_string(), "\"new-etag-value\"".to_string())],
400 );
401 assert_eq!(cond.etag, Some("new-etag-value".to_string()));
402 }
403
404 #[test]
405 fn test_conditional_headers_last_modified() {
406 let mut cond = ConditionalRequest::new();
407
408 cond.last_modified = Some(SimpleDateTime::from_timestamp(784111777));
411
412 let headers = cond.to_headers();
413
414 assert!(headers.iter().any(|(k, _)| k == "If-Modified-Since"));
416 assert!(headers.iter().any(|(k, _)| k == "If-Unmodified-Since"));
417
418 let ims_header = headers
420 .iter()
421 .find(|(k, _)| k == "If-Modified-Since")
422 .unwrap();
423 assert!(ims_header.1.contains("GMT"), "Date should end with GMT");
424 assert!(
425 ims_header.1.contains(","),
426 "Date should have comma after weekday"
427 );
428 }
429
430 #[test]
431 fn test_update_from_response_304() {
432 let mut cond = ConditionalRequest::new();
433
434 cond.update_from_response(
436 304,
437 &[
438 ("ETag".to_string(), "\"static-content-v1\"".to_string()),
439 (
440 "Last-Modified".to_string(),
441 "Mon, 01 Jan 2024 00:00:00 GMT".to_string(),
442 ),
443 ],
444 );
445
446 assert!(
448 cond.should_skip(),
449 "should_skip should be true after 304 response"
450 );
451 assert!(cond.not_modified, "not_modified flag should be set");
452
453 assert_eq!(cond.etag, Some("static-content-v1".to_string()));
455 assert!(cond.last_modified.is_some());
456 }
457
458 #[test]
459 fn test_handle_status_416_needs_full_redownload() {
460 let mut cond = ConditionalRequest::new();
461 cond.content_length = Some(1000); let action = handle_resume_status(416, &cond);
465
466 assert_eq!(
467 action,
468 ResumeAction::RedownloadFull,
469 "416 should trigger RedownloadFull"
470 );
471
472 cond.update_from_response(416, &[]);
474 assert!(
475 cond.needs_full_redownload(),
476 "After 416, needs_full_redownload should be true"
477 );
478 assert!(
479 cond.content_length.is_none(),
480 "content_length should be cleared after 416"
481 );
482
483 assert_eq!(
485 handle_resume_status(304, &cond),
486 ResumeAction::SkipUnchanged
487 );
488 assert_eq!(handle_resume_status(200, &cond), ResumeAction::Continue);
489 assert_eq!(handle_resume_status(206, &cond), ResumeAction::Continue);
490 assert_eq!(handle_resume_status(503, &cond), ResumeAction::RetryLater);
491 }
492
493 #[test]
494 fn test_smart_resume_manager() {
495 let mut manager = SmartResumeManager::new();
496
497 let entry = manager.get_or_create("download-1");
499 entry.etag = Some("\"file-v1\"".to_string());
500
501 let entry2 = manager.get_or_create("download-1");
503 assert_eq!(
504 entry2.etag,
505 Some("\"file-v1\"".to_string()),
506 "Should retrieve existing entry"
507 );
508
509 manager.mark_unchanged("download-1");
511 assert!(
512 manager.is_all_unchanged(&["download-1"]),
513 "Should report as unchanged"
514 );
515
516 manager.get_or_create("download-2").not_modified = true;
518 assert!(
519 manager.is_all_unchanged(&["download-1", "download-2"]),
520 "All should be unchanged"
521 );
522
523 manager.get_or_create("download-3"); assert!(
525 !manager.is_all_unchanged(&["download-1", "download-3"]),
526 "Not all unchanged"
527 );
528 }
529
530 #[test]
531 fn test_can_resume_and_needs_full_redownload() {
532 let mut cond = ConditionalRequest::new();
533
534 assert!(
536 !cond.can_resume(100),
537 "Cannot resume without content length"
538 );
539 assert!(
540 cond.needs_full_redownload(),
541 "Needs full redownload without content length"
542 );
543
544 cond.content_length = Some(1000);
546 assert!(
547 cond.can_resume(500),
548 "Can resume when local file is smaller"
549 );
550 assert!(
551 !cond.can_resume(1000),
552 "Cannot resume when local file equals content length"
553 );
554 assert!(
555 !cond.can_resume(1500),
556 "Cannot resume when local file is larger"
557 );
558 assert!(
559 !cond.needs_full_redownload(),
560 "Doesn't need full redownload with content length"
561 );
562 }
563
564 #[test]
565 fn test_simple_datetime_parsing() {
566 let dt = SimpleDateTime::parse_rfc2822("Sun, 06 Nov 1994 08:49:37 GMT");
568 assert!(dt.is_some(), "Should parse valid RFC 2822 date");
569
570 let dt = dt.unwrap();
571 let formatted = dt.format_imf_fixdate();
572
573 assert!(
575 formatted.ends_with("GMT"),
576 "Formatted date should end with GMT"
577 );
578
579 let invalid = SimpleDateTime::parse_rfc2822("invalid-date");
581 assert!(invalid.is_none(), "Should return None for invalid date");
582 }
583
584 #[test]
585 fn test_conditional_request_with_range() {
586 let mut cond = ConditionalRequest::new();
587 cond.content_length = Some(1024 * 1024); let headers = cond.to_headers();
590
591 let range_header = headers.iter().find(|(k, _)| k == "Range");
593 assert!(
594 range_header.is_some(),
595 "Should have Range header when content_length is set"
596 );
597 let expected = format!("bytes={}-", cond.content_length.unwrap());
599 assert_eq!(range_header.unwrap().1, expected);
600 }
601}