Skip to main content

pulith_fetch/config/
fetch_options.rs

1use std::fmt;
2use std::future::Future;
3use std::pin::Pin;
4use std::sync::Arc;
5use std::time::Duration;
6
7use crate::progress::Progress;
8
9pub type ProgressCallback = Arc<dyn Fn(&Progress) + Send + Sync>;
10pub type RetryDelayFuture = Pin<Box<dyn Future<Output = ()> + Send + 'static>>;
11pub type RetryDelayProvider = Arc<dyn Fn(Duration) -> RetryDelayFuture + Send + Sync>;
12
13/// Explicit retry behavior for transient transfer failures.
14///
15/// Total attempts are `1 + max_retries`.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct RetryPolicy {
18    /// Number of retries after the initial attempt.
19    pub max_retries: u32,
20    /// Base exponential backoff duration.
21    pub base_backoff: Duration,
22}
23
24impl Default for RetryPolicy {
25    fn default() -> Self {
26        Self {
27            max_retries: 3,
28            base_backoff: Duration::from_millis(100),
29        }
30    }
31}
32
33/// Phases of a download operation.
34///
35/// Downloads progress through these phases in order:
36/// Connecting → Downloading → Verifying → Committing → Completed
37///
38/// Retries return to the Connecting phase.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40pub enum FetchPhase {
41    /// Initial state, connection in progress.
42    ///
43    /// This phase is active while establishing the HTTP connection
44    /// and waiting for the first response bytes.
45    #[default]
46    Connecting,
47
48    /// Actively streaming data to disk.
49    ///
50    /// This phase is active while downloading chunks from the server
51    /// and writing them to the staging file.
52    Downloading,
53
54    /// Computing and verifying checksum.
55    ///
56    /// This phase is active after all bytes are downloaded and the
57    /// checksum is being finalized and compared (if configured).
58    Verifying,
59
60    /// Performing atomic commit of the downloaded file.
61    ///
62    /// This phase is active while moving the staging file to its
63    /// final destination path.
64    Committing,
65
66    /// Download completed successfully.
67    ///
68    /// This is the terminal state for successful downloads.
69    Completed,
70}
71
72impl std::fmt::Display for FetchPhase {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        match self {
75            FetchPhase::Connecting => write!(f, "Connecting"),
76            FetchPhase::Downloading => write!(f, "Downloading"),
77            FetchPhase::Verifying => write!(f, "Verifying"),
78            FetchPhase::Committing => write!(f, "Committing"),
79            FetchPhase::Completed => write!(f, "Completed"),
80        }
81    }
82}
83
84/// Configuration for HTTP fetching operations.
85///
86/// # Examples
87///
88/// ```
89/// use pulith_fetch::FetchOptions;
90/// use std::time::Duration;
91///
92/// let options = FetchOptions::default()
93///     .max_retries(5)
94///     .retry_backoff(Duration::from_millis(200))
95///     .header("Authorization", "Bearer token");
96/// ```
97#[derive(Clone)]
98pub struct FetchOptions {
99    /// Expected SHA-256 checksum for verification (optional).
100    /// If provided, the download will be verified and will fail on mismatch.
101    pub checksum: Option<[u8; 32]>,
102
103    /// Retry execution policy for transient transfer failures.
104    pub retry_policy: RetryPolicy,
105
106    /// Expected total bytes for this transfer, when known by caller.
107    pub expected_bytes: Option<u64>,
108
109    /// Resume offset in bytes. When set, fetcher will request `Range: bytes=<offset>-`.
110    pub resume_offset: Option<u64>,
111
112    /// Custom HTTP headers to include with requests.
113    ///
114    /// Headers are sent with every request, including retries.
115    ///
116    /// Default: empty
117    pub headers: Arc<[(String, String)]>,
118
119    /// Progress callback invoked on state transitions and chunk writes.
120    ///
121    /// The callback is invoked:
122    /// - On phase transitions (Connecting → Downloading → Verifying → Committing → Completed)
123    /// - After each chunk write (during Downloading phase, typically every ~8KB)
124    /// - After each retry attempt (back to Connecting phase)
125    ///
126    /// The callback receives a reference to avoid cloning on every invocation.
127    ///
128    /// Default: None
129    pub on_progress: Option<ProgressCallback>,
130
131    /// Optional runtime delay provider for retry backoff sleeping.
132    ///
133    /// When absent, fetcher uses the crate default async sleep mechanism.
134    pub retry_delay_provider: Option<RetryDelayProvider>,
135}
136
137impl fmt::Debug for FetchOptions {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        f.debug_struct("FetchOptions")
140            .field("checksum", &self.checksum)
141            .field("retry_policy", &self.retry_policy)
142            .field("expected_bytes", &self.expected_bytes)
143            .field("resume_offset", &self.resume_offset)
144            .field("headers", &self.headers)
145            .field("on_progress", &"{ ... }")
146            .field("retry_delay_provider", &"{ ... }")
147            .finish()
148    }
149}
150
151impl Default for FetchOptions {
152    fn default() -> Self {
153        Self {
154            checksum: None,
155            retry_policy: RetryPolicy::default(),
156            expected_bytes: None,
157            resume_offset: None,
158            headers: Arc::new([]),
159            on_progress: None,
160            retry_delay_provider: None,
161        }
162    }
163}
164
165impl FetchOptions {
166    /// Set the expected checksum.
167    ///
168    /// # Examples
169    ///
170    /// ```
171    /// use pulith_fetch::FetchOptions;
172    ///
173    /// let hash = [0u8; 32]; // Your expected SHA-256
174    /// let options = FetchOptions::default().checksum(Some(hash));
175    /// ```
176    #[must_use]
177    pub fn checksum(mut self, checksum: Option<[u8; 32]>) -> Self {
178        self.checksum = checksum;
179        self
180    }
181
182    /// Set the maximum number of retries.
183    ///
184    /// # Examples
185    ///
186    /// ```
187    /// use pulith_fetch::FetchOptions;
188    ///
189    /// let options = FetchOptions::default().max_retries(5);
190    /// ```
191    #[must_use]
192    pub fn max_retries(mut self, max_retries: u32) -> Self {
193        self.retry_policy.max_retries = max_retries;
194        self
195    }
196
197    /// Set the base retry backoff duration.
198    ///
199    /// # Examples
200    ///
201    /// ```
202    /// use pulith_fetch::FetchOptions;
203    /// use std::time::Duration;
204    ///
205    /// let options = FetchOptions::default()
206    ///     .retry_backoff(Duration::from_millis(200));
207    /// ```
208    #[must_use]
209    pub fn retry_backoff(mut self, retry_backoff: Duration) -> Self {
210        self.retry_policy.base_backoff = retry_backoff;
211        self
212    }
213
214    #[must_use]
215    /// Set the full retry policy object directly.
216    pub fn retry_policy(mut self, retry_policy: RetryPolicy) -> Self {
217        self.retry_policy = retry_policy;
218        self
219    }
220
221    #[must_use]
222    /// Set expected transfer size for progress/reporting without HEAD lookup.
223    pub fn expected_bytes(mut self, expected_bytes: Option<u64>) -> Self {
224        self.expected_bytes = expected_bytes;
225        self
226    }
227
228    #[must_use]
229    /// Set resume offset in bytes for ranged fetch.
230    pub fn resume_offset(mut self, resume_offset: Option<u64>) -> Self {
231        self.resume_offset = resume_offset;
232        self
233    }
234
235    /// Add a single custom HTTP header.
236    ///
237    /// # Examples
238    ///
239    /// ```
240    /// use pulith_fetch::FetchOptions;
241    ///
242    /// let options = FetchOptions::default()
243    ///     .header("Authorization", "Bearer token")
244    ///     .header("User-Agent", "MyApp/1.0");
245    /// ```
246    #[must_use]
247    pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
248        let mut headers: Vec<_> = self.headers.iter().cloned().collect();
249        headers.push((key.into(), value.into()));
250        self.headers = Arc::from(headers);
251        self
252    }
253
254    /// Set multiple custom HTTP headers at once.
255    ///
256    /// This replaces any existing headers.
257    ///
258    /// # Examples
259    ///
260    /// ```
261    /// use pulith_fetch::FetchOptions;
262    ///
263    /// let headers = vec![
264    ///     ("Authorization".to_string(), "Bearer token".to_string()),
265    ///     ("User-Agent".to_string(), "MyApp/1.0".to_string()),
266    /// ];
267    /// let options = FetchOptions::default().headers(headers);
268    /// ```
269    #[must_use]
270    pub fn headers(mut self, headers: Vec<(String, String)>) -> Self {
271        self.headers = Arc::from(headers);
272        self
273    }
274
275    /// Set the progress callback.
276    ///
277    /// # Examples
278    ///
279    /// ```
280    /// use pulith_fetch::{FetchOptions, FetchPhase};
281    /// use std::sync::Arc;
282    ///
283    /// let options = FetchOptions::default()
284    ///     .on_progress(Arc::new(|progress| {
285    ///         match progress.phase {
286    ///             FetchPhase::Downloading => {
287    ///                 if let Some(pct) = progress.percentage() {
288    ///                     println!("Progress: {:.1}%", pct);
289    ///                 }
290    ///             }
291    ///             FetchPhase::Completed => println!("Done!"),
292    ///             _ => {}
293    ///         }
294    ///     }));
295    /// ```
296    #[must_use]
297    pub fn on_progress(mut self, on_progress: Arc<dyn Fn(&Progress) + Send + Sync>) -> Self {
298        self.on_progress = Some(on_progress);
299        self
300    }
301
302    #[must_use]
303    /// Set an async retry-delay provider for runtime-agnostic backoff handling.
304    pub fn retry_delay_provider(mut self, provider: RetryDelayProvider) -> Self {
305        self.retry_delay_provider = Some(provider);
306        self
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use std::sync::atomic::{AtomicU32, Ordering};
314
315    #[test]
316    fn test_fetch_phase_display() {
317        assert_eq!(FetchPhase::Connecting.to_string(), "Connecting");
318        assert_eq!(FetchPhase::Downloading.to_string(), "Downloading");
319        assert_eq!(FetchPhase::Verifying.to_string(), "Verifying");
320        assert_eq!(FetchPhase::Committing.to_string(), "Committing");
321        assert_eq!(FetchPhase::Completed.to_string(), "Completed");
322    }
323
324    #[test]
325    fn test_fetch_phase_default() {
326        assert_eq!(FetchPhase::default(), FetchPhase::Connecting);
327    }
328
329    #[test]
330    fn test_fetch_options_default() {
331        let options = FetchOptions::default();
332        assert!(options.checksum.is_none());
333        assert_eq!(options.retry_policy.max_retries, 3);
334        assert_eq!(
335            options.retry_policy.base_backoff,
336            Duration::from_millis(100)
337        );
338        assert_eq!(options.expected_bytes, None);
339        assert_eq!(options.resume_offset, None);
340        assert!(options.headers.is_empty());
341        assert!(options.on_progress.is_none());
342        assert!(options.retry_delay_provider.is_none());
343    }
344
345    #[test]
346    fn test_fetch_options_checksum() {
347        let hash = [1u8; 32];
348        let options = FetchOptions::default().checksum(Some(hash));
349        assert_eq!(options.checksum, Some(hash));
350
351        let options = FetchOptions::default().checksum(None);
352        assert!(options.checksum.is_none());
353    }
354
355    #[test]
356    fn test_fetch_options_max_retries() {
357        let options = FetchOptions::default().max_retries(5);
358        assert_eq!(options.retry_policy.max_retries, 5);
359
360        let options = FetchOptions::default().max_retries(0);
361        assert_eq!(options.retry_policy.max_retries, 0);
362    }
363
364    #[test]
365    fn test_fetch_options_retry_backoff() {
366        let duration = Duration::from_secs(1);
367        let options = FetchOptions::default().retry_backoff(duration);
368        assert_eq!(options.retry_policy.base_backoff, duration);
369    }
370
371    #[test]
372    fn test_fetch_options_resume_and_expected_bytes() {
373        let options = FetchOptions::default()
374            .resume_offset(Some(128))
375            .expected_bytes(Some(512));
376        assert_eq!(options.resume_offset, Some(128));
377        assert_eq!(options.expected_bytes, Some(512));
378    }
379
380    #[test]
381    fn test_fetch_options_header() {
382        let options = FetchOptions::default()
383            .header("Authorization", "Bearer token")
384            .header("User-Agent", "MyApp/1.0");
385
386        let headers: Vec<_> = options.headers.iter().cloned().collect();
387        assert_eq!(headers.len(), 2);
388        assert!(headers.contains(&("Authorization".to_string(), "Bearer token".to_string())));
389        assert!(headers.contains(&("User-Agent".to_string(), "MyApp/1.0".to_string())));
390    }
391
392    #[test]
393    fn test_fetch_options_headers() {
394        let headers = vec![
395            ("Authorization".to_string(), "Bearer token".to_string()),
396            ("User-Agent".to_string(), "MyApp/1.0".to_string()),
397        ];
398        let options = FetchOptions::default().headers(headers.clone());
399
400        let options_headers: Vec<_> = options.headers.iter().cloned().collect();
401        assert_eq!(options_headers, headers);
402    }
403
404    #[test]
405    fn test_fetch_options_headers_replace() {
406        let options = FetchOptions::default()
407            .header("Old", "value")
408            .headers(vec![("New".to_string(), "value".to_string())]);
409
410        let headers: Vec<_> = options.headers.iter().cloned().collect();
411        assert_eq!(headers.len(), 1);
412        assert!(headers.contains(&("New".to_string(), "value".to_string())));
413        assert!(!headers.iter().any(|(k, _)| k == "Old"));
414    }
415
416    #[test]
417    fn test_fetch_options_on_progress() {
418        let call_count = Arc::new(AtomicU32::new(0));
419        let call_count_clone = call_count.clone();
420
421        let options = FetchOptions::default().on_progress(Arc::new(move |_| {
422            call_count_clone.fetch_add(1, Ordering::SeqCst);
423        }));
424
425        assert!(options.on_progress.is_some());
426
427        if let Some(callback) = &options.on_progress {
428            let progress = Progress {
429                phase: FetchPhase::Downloading,
430                bytes_downloaded: 100,
431                total_bytes: Some(1000),
432                retry_count: 0,
433                performance_metrics: None,
434            };
435            callback(&progress);
436            assert_eq!(call_count.load(Ordering::SeqCst), 1);
437        }
438    }
439
440    #[test]
441    fn test_fetch_options_debug() {
442        let options = FetchOptions::default()
443            .checksum(Some([1u8; 32]))
444            .max_retries(5)
445            .header("Test", "value");
446
447        let debug_str = format!("{:?}", options);
448        assert!(debug_str.contains("FetchOptions"));
449        assert!(debug_str.contains("checksum: Some(["));
450        assert!(debug_str.contains("retry_policy"));
451        assert!(debug_str.contains("{ ... }"));
452    }
453
454    #[test]
455    fn test_fetch_options_builder_pattern() {
456        let hash = [2u8; 32];
457        let options = FetchOptions::default()
458            .checksum(Some(hash))
459            .max_retries(10)
460            .retry_backoff(Duration::from_millis(500))
461            .header("Custom", "header");
462
463        assert_eq!(options.checksum, Some(hash));
464        assert_eq!(options.retry_policy.max_retries, 10);
465        assert_eq!(
466            options.retry_policy.base_backoff,
467            Duration::from_millis(500)
468        );
469        assert_eq!(options.headers.len(), 1);
470        assert!(options.retry_delay_provider.is_none());
471
472        // Test with headers() replacing
473        let options2 = FetchOptions::default()
474            .checksum(Some(hash))
475            .max_retries(10)
476            .retry_backoff(Duration::from_millis(500))
477            .headers(vec![("Another".to_string(), "header".to_string())]);
478
479        assert_eq!(options2.checksum, Some(hash));
480        assert_eq!(options2.retry_policy.max_retries, 10);
481        assert_eq!(
482            options2.retry_policy.base_backoff,
483            Duration::from_millis(500)
484        );
485        assert_eq!(options2.headers.len(), 1);
486        assert!(options2.retry_delay_provider.is_none());
487    }
488
489    #[test]
490    fn test_fetch_options_retry_delay_provider() {
491        let options =
492            FetchOptions::default().retry_delay_provider(Arc::new(|_| Box::pin(async {})));
493        assert!(options.retry_delay_provider.is_some());
494    }
495
496    #[test]
497    fn test_fetch_options_clone() {
498        let options = FetchOptions::default()
499            .checksum(Some([3u8; 32]))
500            .header("Test", "value");
501
502        let cloned = options.clone();
503        assert_eq!(cloned.checksum, options.checksum);
504        assert_eq!(cloned.retry_policy, options.retry_policy);
505        assert_eq!(cloned.headers.as_ptr(), options.headers.as_ptr()); // Same Arc
506    }
507}