Skip to main content

fast_down_api/
config.rs

1use fast_down::{Merge, ProgressEntry, Proxy};
2use inherit_config::InheritConfig;
3use serde::{Deserialize, Serialize};
4use std::{collections::HashMap, net::IpAddr, path::PathBuf, time::Duration};
5
6/// File write method for downloaded data.
7///
8/// - `Mmap`: memory-mapped I/O (fastest, default)
9/// - `Std`: buffered standard file I/O
10#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
11pub enum WriteMethod {
12    #[default]
13    Mmap,
14    Std,
15}
16
17/// Configuration for a download task.
18///
19/// All fields have sensible defaults; see [`Config::default`] for values.
20#[derive(Debug, Clone, Serialize, Deserialize, InheritConfig)]
21#[allow(clippy::struct_excessive_bools)]
22pub struct Config {
23    /// 保存的文件夹
24    pub save_dir: PathBuf,
25
26    /// 文件名解析
27    pub parse_filename: bool,
28
29    /// 文件名
30    pub filename: String,
31
32    /// Number of threads. Recommended: `32` / `16` / `8`. More threads does not always mean faster.
33    #[config(default = 32)]
34    pub threads: usize,
35
36    /// Proxy setting. Supports https, http, and socks5 proxies.
37    pub proxy: Proxy<String>,
38
39    /// Custom request headers.
40    pub headers: HashMap<String, String>,
41
42    /// Minimum chunk size in bytes. Recommended: `1024 * 1024`
43    ///
44    /// - Chunks that are too small may cause heavy contention.
45    /// - When chunking is no longer possible, speculative mode is used.
46    #[config(default = 1024 * 1024)]
47    pub min_chunk_size: u64,
48
49    /// Whether to ensure data is fully flushed to disk. Recommended: `false`
50    ///
51    /// Set to `true` only if you need to power off immediately after download.
52    pub sync_all: bool,
53
54    /// Reserve the whole file size on disk before downloading. Recommended: `false`
55    ///
56    /// Claiming the space up front keeps the file in fewer fragments and turns
57    /// a full disk into an error before any bytes are fetched instead of
58    /// halfway through. The cost depends on the platform: `fallocate` on Unix
59    /// and `SetFileValidData` on Windows reserve the space instantly, but
60    /// without them the fallback writes zeros across the whole file, which
61    /// takes as long as a full-size write pass.
62    ///
63    /// `SetFileValidData` needs the `SeManageVolumePrivilege`, which is
64    /// enabled automatically when available; without it Windows falls back to
65    /// the zero-fill path. Failure to reserve is reported as
66    /// [`crate::Event::AllocError`] and does not stop the download.
67    pub pre_alloc: bool,
68
69    /// Write buffer size in bytes. Recommended: `16 * 1024 * 1024`
70    ///
71    /// - Only effective for [`WriteMethod::Std`]. Reduces the number of `write` syscalls
72    ///   by batching small writes into larger ones via `BufWriter`.
73    /// - Not used for [`WriteMethod::Mmap`], as the buffer is managed by the OS.
74    #[config(default = 16 * 1024 * 1024)]
75    pub write_buffer_size: usize,
76
77    /// Cache high watermark in bytes. Recommended: `16 * 1024 * 1024`
78    ///
79    /// When the byte merge buffer reaches this size, a merge flush is triggered
80    /// to reduce the buffer to `cache_low_watermark` or below.
81    ///
82    /// - Only effective for [`WriteMethod::Std`].
83    /// - Not used for [`WriteMethod::Mmap`].
84    #[config(default = 16 * 1024 * 1024)]
85    pub cache_high_watermark: usize,
86
87    /// Cache low watermark in bytes. Recommended: `8 * 1024 * 1024`
88    ///
89    /// After a merge flush, the byte merge buffer size is reduced to this level or below.
90    ///
91    /// - Only effective for [`WriteMethod::Std`].
92    /// - Not used for [`WriteMethod::Mmap`].
93    #[config(default = 8 * 1024 * 1024)]
94    pub cache_low_watermark: usize,
95
96    /// Write queue capacity. Recommended: `10240`
97    ///
98    /// If download threads fill the write queue, backpressure is applied to
99    /// slow down downloads and prevent excessive memory usage.
100    #[config(default = 10240)]
101    pub write_queue_cap: usize,
102
103    /// Default retry interval after a request failure. Recommended: `500ms`
104    ///
105    /// If the server returns a `Retry-After` header, that value takes precedence.
106    #[config(default = Duration::from_millis(500))]
107    #[config(partial_attr(serde(with = "humantime_serde::option")))]
108    #[config(partial_attr(serde(default)))]
109    pub retry_gap: Duration,
110
111    /// Pull timeout. Recommended: `5s`
112    ///
113    /// If no bytes are received within `pull_timeout` after sending the request,
114    /// the connection is dropped and re-established. This helps TCP detect
115    /// congestion and can improve download speed.
116    #[config(default = Duration::from_secs(5))]
117    #[config(partial_attr(serde(with = "humantime_serde::option")))]
118    #[config(partial_attr(serde(default)))]
119    pub pull_timeout: Duration,
120
121    /// Minimum interval between [`crate::Event::Progress`] emissions. Recommended: `500ms`
122    ///
123    /// The progress reporter runs on its own timer, so this cadence is driven
124    /// purely by `progress_emit_gap` and is never delayed by other download work
125    /// (flushing, state saving, event forwarding) or by a slow consumer — the
126    /// event channel is unbounded, so [`crate::Event::Progress`] is sent without
127    /// blocking. Set it smaller for a smoother progress bar, larger to cut channel
128    /// traffic. `Duration::ZERO` emits at the maximum rate (busy-loop, not recommended).
129    #[config(default = Duration::from_millis(500))]
130    #[config(partial_attr(serde(with = "humantime_serde::option")))]
131    #[config(partial_attr(serde(default)))]
132    pub progress_emit_gap: Duration,
133
134    /// Whether to accept invalid certificates (dangerous). Recommended: `false`
135    pub accept_invalid_certs: bool,
136
137    /// Whether to accept invalid hostnames (dangerous). Recommended: `false`
138    pub accept_invalid_hostnames: bool,
139
140    /// Write method. Recommended: [`WriteMethod::Mmap`]
141    ///
142    /// - [`WriteMethod::Mmap`] is fastest — it delegates writes to the OS, but:
143    ///     1. On 32-bit systems, the maximum file size is 4 GB, so it automatically
144    ///        falls back to [`WriteMethod::Std`].
145    ///     2. Mmap requires the file size to be known and byte-range support from
146    ///        the server; when the `fast_download` flag (set during prefetch) is false,
147    ///        it falls back to [`WriteMethod::Std`].
148    ///     3. In rare cases, the OS may cache all data in memory and flush it all
149    ///        at once after the download completes, causing a long post-download delay.
150    /// - [`WriteMethod::Std`] has the best compatibility. Out-of-order chunks are
151    ///   re-ordered into sequential order by the cache layer before being written.
152    pub write_method: WriteMethod,
153
154    /// Number of retries for fetching metadata. Recommended: `10`. Note: this is not
155    /// the retry count during download.
156    #[config(default = 10)]
157    pub retry_times: usize,
158
159    /// Local IP addresses to bind for outgoing requests. Recommended: `Vec::new()`
160    ///
161    /// If you have multiple network interfaces, you can provide their IP addresses;
162    /// each time the puller is cloned (e.g. on retry or work-stealing) the next
163    /// address in the list is used. This may not always improve speed.
164    pub local_address: Vec<IpAddr>,
165
166    /// Maximum number of speculative workers. Recommended: `3`
167    ///
168    /// When the remaining chunk is smaller than `min_chunk_size` and cannot be split,
169    /// speculative mode is used. Up to `max_speculative` workers compete on the same
170    /// chunk to prevent the download from stalling near 99%.
171    #[config(default = 3)]
172    pub max_speculative: usize,
173
174    /// Already downloaded chunks (resume progress), stored as an HTTP `Range`-style
175    /// list, e.g. `downloaded_chunk = "1-3,4-9"`. Ends are *inclusive*, matching the
176    /// HTTP `Range` header (`bytes=1-3` covers bytes 1,2,3); the internal half-open
177    /// `start..end` is mapped to `start-(end-1)` on disk. Absent when nothing has
178    /// been downloaded yet.
179    #[config(partial_attr(serde(with = "range_list")))]
180    #[config(partial_attr(serde(default)))]
181    pub downloaded_chunk: Vec<ProgressEntry>,
182
183    /// Smoothing window for downloaded chunks in bytes. Recommended: `8 * 1024`
184    ///
185    /// Filters out small gaps in `downloaded_chunk` that are smaller than
186    /// `chunk_window` to reduce the number of HTTP requests.
187    #[config(default = 8 * 1024)]
188    pub chunk_window: u64,
189
190    /// Maximum number of redirects. Recommended value: `20`
191    #[config(default = 20)]
192    pub max_redirects: usize,
193
194    /// Enable cookie store. When `true`, the client will automatically save
195    /// `Set-Cookie` headers from responses and send matching cookies in
196    /// subsequent requests (including across redirects).
197    pub cookie_store: bool,
198
199    /// 是否尝试断点续传,推荐值: `true`
200    #[config(default = true)]
201    pub resume: bool,
202
203    /// 是否覆盖已存在的文件,推荐值: `false`
204    pub overwrite: bool,
205}
206
207impl PartialConfig {
208    /// Merge a freshly-written byte range into this partial config's progress.
209    ///
210    /// The range is folded into `downloaded_chunk` (created if absent), keeping
211    /// it normalized and de-duplicated. This is the in-memory counterpart of
212    /// [`DownloadState::merge_progress`](crate::DownloadState::merge_progress):
213    /// callers use it to record progress before handing the config to
214    /// [`resume`](crate::resume).
215    pub fn merge_progress(&mut self, progress: ProgressEntry) {
216        self.downloaded_chunk
217            .get_or_insert_default()
218            .merge_progress(progress);
219    }
220}
221
222#[cfg(test)]
223#[allow(clippy::single_range_in_vec_init)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn merge_progress_none_to_some() {
229        let mut c = PartialConfig::default();
230        assert_eq!(c.downloaded_chunk, None);
231        c.merge_progress(1u64..5);
232        assert_eq!(c.downloaded_chunk, Some(vec![1u64..5]));
233    }
234
235    #[test]
236    fn merge_progress_coalesces() {
237        let mut c = PartialConfig::default();
238        c.merge_progress(1u64..5);
239        c.merge_progress(5u64..10);
240        c.merge_progress(10u64..20);
241        assert_eq!(c.downloaded_chunk, Some(vec![1u64..20]));
242    }
243
244    #[test]
245    fn merge_progress_empty_is_noop() {
246        let mut c = PartialConfig::default();
247        c.merge_progress(1u64..5);
248        c.merge_progress(3u64..3);
249        assert_eq!(c.downloaded_chunk, Some(vec![1u64..5]));
250    }
251
252    #[test]
253    fn merge_progress_disjoint() {
254        let mut c = PartialConfig::default();
255        c.merge_progress(1u64..5);
256        c.merge_progress(10u64..20);
257        assert_eq!(c.downloaded_chunk, Some(vec![1u64..5, 10u64..20]));
258    }
259}
260
261/// HTTP `Range`-style (de)serialization for [`Config::downloaded_chunk`].
262///
263/// The internal [`ProgressEntry`] is a half-open `start..end` range, while the
264/// HTTP `Range` header uses an *inclusive* end (`bytes=1-3` covers bytes 1,2,3).
265/// We follow that on-disk convention, so `start..end` is written as
266/// `"{start}-{end-1}"` and parsed back as `{start}..{end+1}`. The round-trip is
267/// lossless for any `u64` range and stays a single TOML string, e.g.
268/// `downloaded_chunk = "1-3,4-9"`.
269mod range_list {
270    use fast_down::ProgressEntry;
271    use serde::{Deserialize, Deserializer, Serializer};
272
273    #[allow(clippy::ref_option)]
274    pub fn serialize<S: Serializer>(
275        value: &Option<Vec<ProgressEntry>>,
276        serializer: S,
277    ) -> Result<S::Ok, S::Error> {
278        match value {
279            None => serializer.serialize_none(),
280            Some(ranges) => {
281                let text = ranges
282                    .iter()
283                    .map(|r| -> Result<String, S::Error> {
284                        let end_inclusive = r.end.checked_sub(1).ok_or_else(|| {
285                            serde::ser::Error::custom(format!(
286                                "downloaded_chunk range must be non-empty (end > 0), got {r:?}"
287                            ))
288                        })?;
289                        if r.start > end_inclusive {
290                            return Err(serde::ser::Error::custom(format!(
291                                "downloaded_chunk range must be non-empty (start <= end), got {r:?}"
292                            )));
293                        }
294                        Ok(format!("{}-{}", r.start, end_inclusive))
295                    })
296                    .collect::<Result<Vec<_>, S::Error>>()?
297                    .join(",");
298                serializer.serialize_str(&text)
299            }
300        }
301    }
302
303    pub fn deserialize<'de, D: Deserializer<'de>>(
304        deserializer: D,
305    ) -> Result<Option<Vec<ProgressEntry>>, D::Error> {
306        let raw = Option::<String>::deserialize(deserializer)?;
307        match raw {
308            None => Ok(None),
309            Some(text) => {
310                let trimmed = text.trim();
311                if trimmed.is_empty() {
312                    return Ok(Some(Vec::new()));
313                }
314                let mut ranges = Vec::new();
315                for part in trimmed.split(',') {
316                    let part = part.trim();
317                    let (start_repr, end_repr) = part.split_once('-').ok_or_else(|| {
318                        serde::de::Error::custom(format!(
319                            "invalid range `{part}` in downloaded_chunk"
320                        ))
321                    })?;
322                    let start: u64 = start_repr.trim().parse().map_err(|e| {
323                        serde::de::Error::custom(format!("invalid range start `{start_repr}`: {e}"))
324                    })?;
325                    let end_inclusive: u64 = end_repr.trim().parse().map_err(|e| {
326                        serde::de::Error::custom(format!("invalid range end `{end_repr}`: {e}"))
327                    })?;
328                    if end_inclusive == u64::MAX {
329                        return Err(serde::de::Error::custom(format!(
330                            "invalid range `{part}` in downloaded_chunk: end {end_inclusive} is a malformed (wrapping) value"
331                        )));
332                    }
333                    if end_inclusive < start {
334                        return Err(serde::de::Error::custom(format!(
335                            "invalid range `{part}` in downloaded_chunk: end {end_inclusive} < start {start}"
336                        )));
337                    }
338                    ranges.push(start..end_inclusive.saturating_add(1));
339                }
340                // Normalize to ascending `start` order on load so downstream
341                // consumers (resume gap computation, `part_shortfall`, ...) can
342                // rely on the sorted invariant even when the `.fd` was hand-edited
343                // or written by an older build that stored chunks out of order.
344                ranges.sort_by_key(|r| r.start);
345                Ok(Some(ranges))
346            }
347        }
348    }
349}
350
351#[cfg(test)]
352mod range_list_tests {
353    use super::*;
354
355    #[test]
356    fn downloaded_chunk_round_trips_as_http_range_string() {
357        let pc = PartialConfig {
358            downloaded_chunk: Some(vec![1..4, 5..10, 100..101]),
359            ..Default::default()
360        };
361        let toml = toml::to_string(&pc).unwrap();
362        assert!(
363            toml.contains("downloaded_chunk = \"1-3,5-9,100-100\""),
364            "downloaded_chunk should be stored as an HTTP Range string, got:\n{toml}"
365        );
366        let back: PartialConfig = toml::from_str(&toml).unwrap();
367        assert_eq!(back.downloaded_chunk, Some(vec![1..4, 5..10, 100..101]));
368    }
369
370    #[test]
371    fn downloaded_chunk_absent_when_none() {
372        let pc = PartialConfig::default();
373        let toml = toml::to_string(&pc).unwrap();
374        assert!(
375            !toml.contains("downloaded_chunk"),
376            "absent downloaded_chunk must not be serialized, got:\n{toml}"
377        );
378        let back: PartialConfig = toml::from_str(&toml).unwrap();
379        assert_eq!(back.downloaded_chunk, None);
380    }
381
382    #[test]
383    fn downloaded_chunk_empty_string_round_trips_to_empty_vec() {
384        let pc = PartialConfig {
385            downloaded_chunk: Some(Vec::new()),
386            ..Default::default()
387        };
388        let toml = toml::to_string(&pc).unwrap();
389        assert!(
390            toml.contains("downloaded_chunk = \"\""),
391            "an empty downloaded_chunk should serialize as an empty string, got:\n{toml}"
392        );
393        let back: PartialConfig = toml::from_str(&toml).unwrap();
394        assert_eq!(back.downloaded_chunk, Some(Vec::new()));
395    }
396
397    #[test]
398    fn downloaded_chunk_rejects_reversed_range() {
399        let toml = "downloaded_chunk = \"3-1\"\n";
400        let err = toml::from_str::<PartialConfig>(toml).unwrap_err();
401        let msg = err.to_string();
402        assert!(
403            msg.contains("3-1") && msg.contains("downloaded_chunk"),
404            "reversed range must be rejected with a clear error, got: {msg}"
405        );
406    }
407
408    #[test]
409    #[allow(clippy::single_range_in_vec_init)]
410    fn downloaded_chunk_single_byte_range_round_trips() {
411        // HTTP Range `5-5` is a single byte (offset 5), i.e. the half-open `5..6`.
412        let pc = PartialConfig {
413            downloaded_chunk: Some(vec![5..6]),
414            ..Default::default()
415        };
416        let toml = toml::to_string(&pc).unwrap();
417        assert!(
418            toml.contains("downloaded_chunk = \"5-5\""),
419            "single-byte range should keep its inclusive end, got:\n{toml}"
420        );
421        let back: PartialConfig = toml::from_str(&toml).unwrap();
422        assert_eq!(back.downloaded_chunk, Some(vec![5..6]));
423    }
424
425    #[test]
426    fn downloaded_chunk_rejects_range_without_dash() {
427        // No '-' separator: `split_once('-')` fails (deserialize lines 299-303).
428        let toml = "downloaded_chunk = \"5\"\n";
429        let err = toml::from_str::<PartialConfig>(toml).unwrap_err();
430        let msg = err.to_string();
431        assert!(
432            msg.contains("downloaded_chunk"),
433            "missing-dash range must be rejected with a clear error, got: {msg}"
434        );
435    }
436
437    #[test]
438    fn downloaded_chunk_rejects_bad_start() {
439        // Non-numeric start: `start_repr.parse()` fails (lines 304-306).
440        let toml = "downloaded_chunk = \"x-10\"\n";
441        let err = toml::from_str::<PartialConfig>(toml).unwrap_err();
442        let msg = err.to_string();
443        assert!(
444            msg.contains("downloaded_chunk") && msg.contains('x'),
445            "invalid start must be rejected with a clear error, got: {msg}"
446        );
447    }
448
449    #[test]
450    fn downloaded_chunk_rejects_bad_end() {
451        // Non-numeric end: `end_repr.parse()` fails (lines 307-309).
452        let toml = "downloaded_chunk = \"5-y\"\n";
453        let err = toml::from_str::<PartialConfig>(toml).unwrap_err();
454        let msg = err.to_string();
455        assert!(
456            msg.contains("downloaded_chunk") && msg.contains('y'),
457            "invalid end must be rejected with a clear error, got: {msg}"
458        );
459    }
460
461    /// Covers the `None` arm of `range_list::serialize` (`config.rs` line 267).
462    ///
463    /// `PartialConfig` can never reach it: `inherit-config` injects
464    /// `#[serde(skip_serializing_if = "Option::is_none")]` on every partial
465    /// field, so serde skips the field entirely instead of calling the helper.
466    /// A local wrapper without that attribute exercises the arm directly.
467    #[test]
468    fn range_list_serialize_none_emits_nothing() {
469        #[derive(Serialize)]
470        struct Wrapper {
471            #[serde(with = "super::range_list")]
472            downloaded_chunk: Option<Vec<ProgressEntry>>,
473        }
474
475        let toml = toml::to_string(&Wrapper {
476            downloaded_chunk: None,
477        })
478        .unwrap();
479        assert!(
480            toml.is_empty(),
481            "a `None` chunk list must serialize to nothing, got:\n{toml}"
482        );
483    }
484
485    /// Covers the `None` arm of `range_list::deserialize` (`config.rs` line 290).
486    ///
487    /// TOML has no `null` and the partial field carries `serde(default)`, so a
488    /// missing key never calls the helper; the arm is only reachable by feeding
489    /// the helper a deserializer that yields `None` directly.
490    #[test]
491    fn range_list_deserialize_none_yields_none() {
492        let de = serde::de::value::UnitDeserializer::<serde::de::value::Error>::new();
493        let parsed = range_list::deserialize(de).unwrap();
494        assert_eq!(parsed, None);
495    }
496
497    /// Serialize must reject an empty range (start == end) with an error instead
498    /// of relying on a `debug_assert!` that release builds compile out (R3).
499    #[test]
500    #[allow(clippy::single_range_in_vec_init)]
501    fn downloaded_chunk_serialization_rejects_empty_range() {
502        let pc = PartialConfig {
503            downloaded_chunk: Some(vec![1..1]),
504            ..Default::default()
505        };
506        let result = toml::to_string(&pc);
507        assert!(
508            result.is_err(),
509            "an empty downloaded_chunk range must fail to serialize, got: {result:?}"
510        );
511    }
512
513    /// Deserialize must reject the `u64::MAX` inclusive end that a wrapping
514    /// underflow would produce, symmetrically to the serialize-side guard (R3).
515    #[test]
516    fn downloaded_chunk_deserialization_rejects_wrapping_end() {
517        let toml = "downloaded_chunk = \"0-18446744073709551615\"\n";
518        let err = toml::from_str::<PartialConfig>(toml).unwrap_err();
519        let msg = err.to_string();
520        assert!(
521            msg.contains("downloaded_chunk"),
522            "wrapping end must be rejected with a clear error, got: {msg}"
523        );
524    }
525
526    /// Deserialize must normalize chunks to ascending `start` order so downstream
527    /// consumers can rely on the sorted invariant even for hand-edited `.fd` files.
528    #[test]
529    #[allow(clippy::single_range_in_vec_init)]
530    fn downloaded_chunk_deserialize_normalizes_order() {
531        let toml = "downloaded_chunk = \"5-9,1-3,100-100\"\n";
532        let pc: PartialConfig = toml::from_str(toml).unwrap();
533        assert_eq!(pc.downloaded_chunk, Some(vec![1..4, 5..10, 100..101]));
534    }
535}