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}