fast_down_api/core/download/plan/mod.rs
1//! Two-phase download entry point: probe the remote and the disk first, commit
2//! to writing later.
3//!
4//! [`plan`] and [`plan_resume`] perform every step that has no lasting effect —
5//! prefetching the remote metadata, resolving the output path, and inspecting
6//! any `.fd`/`.part` pair already on disk — and hand back a [`DownloadPlan`]
7//! describing what *would* happen. Nothing is created, truncated or renamed
8//! until one of the `start*` methods is called, so a caller can show the plan to
9//! a user, ask a question, and then either commit or drop the plan without
10//! leaving anything behind.
11//!
12//! [`crate::download`] and [`crate::resume`] are the one-shot wrappers over this
13//! pair: they plan and immediately start, reporting a [`PlanError`] as the
14//! matching `*Error` event.
15mod common;
16mod fresh;
17mod manifest;
18mod mismatch;
19mod resume;
20
21use self::{
22 common::{Fallback, PlanCommon},
23 fresh::FreshPlan,
24 manifest::ManifestFreshPlan,
25 mismatch::{MismatchPlan, MismatchPolicy},
26 resume::ResumePlan,
27};
28pub use self::{fresh::plan, manifest::plan_from_fd, resume::peek_resume, resume::plan_resume};
29use super::try_load_resume_state;
30use crate::{
31 Config, DownloadState, Event, PartialConfig, StateError, TerminationReason, Tx, prefetch,
32 utils::gen_path,
33};
34use fast_down::{ProgressEntry, Total, UrlInfo, reqwest::ReqwestResponseError};
35use inherit_config::ConfigLayer;
36use reqwest::Response;
37use std::path::{Path, PathBuf};
38use tokio_util::sync::CancellationToken;
39use url::Url;
40
41/// Why a download plan could not be produced.
42///
43/// Every variant is fatal for the plan: no [`DownloadPlan`] exists, and nothing
44/// has been written to disk. [`PlanError::emit`] maps each one onto the
45/// equivalent [`Event`] for callers that consume the event stream rather than
46/// the return value.
47#[derive(Debug, thiserror::Error)]
48#[allow(clippy::large_enum_variant)]
49pub enum PlanError {
50 /// The HTTP client could not be built (TLS / backend initialization, etc.).
51 #[error("failed to build the HTTP client: {0}")]
52 BuildClient(reqwest::Error),
53 /// Prefetch exhausted its retry budget; this is the failure of the last
54 /// attempt. Earlier attempts were reported as [`Event::PrefetchError`].
55 #[error("failed to fetch the remote metadata: {0}")]
56 Prefetch(ReqwestResponseError),
57 /// The output path could not be computed (unwritable directory, a file name
58 /// that is invalid on this platform, etc.).
59 #[error("failed to compute the output path: {0}")]
60 GenPath(std::io::Error),
61 /// An explicit [`plan_resume`] could not continue the interrupted download.
62 #[error(transparent)]
63 Resume(StateError),
64}
65
66impl PlanError {
67 /// Forward this error to the event channel as the matching `*Error` event.
68 ///
69 /// Used by the one-shot [`crate::download`] / [`crate::resume`] wrappers,
70 /// whose callers only observe the event stream.
71 pub fn emit(self, tx: &Tx) {
72 let _ = match self {
73 Self::BuildClient(e) => tx.send(Event::BuildClientError(e)),
74 Self::Prefetch(e) => tx.send(Event::PrefetchError(e)),
75 Self::GenPath(e) => tx.send(Event::GenPathError(e)),
76 Self::Resume(e) => tx.send(Event::ResumeError(e)),
77 };
78 }
79}
80
81/// What the disk probe found for the download a [`DownloadPlan`] describes.
82#[derive(Debug)]
83#[allow(clippy::large_enum_variant)]
84pub enum ResumeOutcome {
85 /// Nothing to continue from: either no state was found, or resuming is
86 /// disabled / unsupported by the server. Starting the plan downloads the
87 /// whole file.
88 Fresh,
89 /// A `.fd`/`.part` pair was found and validates against the current remote
90 /// file. Starting the plan continues from `progress`.
91 Resumable {
92 /// Path of the `.fd` state file backing the resume.
93 config_path: PathBuf,
94 /// Byte ranges already written to the `.part` file.
95 progress: Vec<ProgressEntry>,
96 /// Total bytes already on disk (the sum of the `progress` lengths).
97 downloaded: u64,
98 },
99 /// A `.fd`/`.part` pair was found but cannot be continued — most often
100 /// because the remote file changed since it was written.
101 ///
102 /// What [`DownloadPlan::start`] does depends on which entry point produced
103 /// the plan. From [`plan`] it falls back to a fresh download (overwriting
104 /// the stale `.part`, or claiming a new name when `overwrite` is disabled).
105 /// From [`plan_resume`] the caller asked for a resume specifically, so it
106 /// refuses instead: [`Event::ResumeError`] and
107 /// [`TerminationReason::Failed`], leaving the `.part` and `.fd` untouched.
108 ///
109 /// Either way [`DownloadPlan::start_forced_resume`] continues from the
110 /// stale state, but only when the mismatch is limited to the identity
111 /// headers.
112 Mismatch(StateError),
113}
114
115enum PlanKind {
116 Fresh(FreshPlan),
117 Resume(ResumePlan),
118 ManifestFresh(ManifestFreshPlan),
119 Mismatch(MismatchPlan),
120}
121
122impl PlanKind {
123 const fn common(&self) -> &PlanCommon {
124 match self {
125 Self::Fresh(plan) => plan.common(),
126 Self::Resume(plan) => plan.common(),
127 Self::ManifestFresh(plan) => plan.common(),
128 Self::Mismatch(plan) => plan.common(),
129 }
130 }
131
132 const fn outcome(&self) -> &ResumeOutcome {
133 match self {
134 Self::Fresh(plan) => plan.outcome(),
135 Self::Resume(plan) => plan.outcome(),
136 Self::ManifestFresh(plan) => plan.outcome(),
137 Self::Mismatch(plan) => plan.outcome(),
138 }
139 }
140
141 fn into_common(self) -> PlanCommon {
142 match self {
143 Self::Fresh(plan) => plan.into_common(),
144 Self::Resume(plan) => plan.into_common(),
145 Self::ManifestFresh(plan) => plan.into_common(),
146 Self::Mismatch(plan) => plan.into_common(),
147 }
148 }
149
150 async fn run(self) -> TerminationReason {
151 match self {
152 Self::Fresh(plan) => Box::pin(plan.run()).await,
153 Self::Resume(plan) => Box::pin(plan.run()).await,
154 Self::ManifestFresh(plan) => Box::pin(plan.run()).await,
155 Self::Mismatch(plan) => Box::pin(plan.run()).await,
156 }
157 }
158
159 async fn run_forced(self) -> TerminationReason {
160 match self {
161 Self::Mismatch(plan) => Box::pin(plan.run_forced()).await,
162 other => Box::pin(other.run()).await,
163 }
164 }
165
166 /// Discard the variant and download the whole file from byte zero,
167 /// ignoring any resumable state on disk.
168 async fn run_fresh(self) -> TerminationReason {
169 self.into_common().claim_and_run().await
170 }
171}
172
173/// A prepared, not-yet-started download.
174///
175/// Produced by [`plan`] / [`plan_resume`] after the remote has been prefetched
176/// and the disk inspected. Holding one keeps the prefetch [`Response`] open so
177/// the first range request can reuse it; dropping one abandons the download
178/// without having touched the filesystem.
179#[must_use = "a DownloadPlan does nothing until one of the start methods is called; drop it to abandon the download"]
180pub struct DownloadPlan {
181 inner: PlanKind,
182}
183
184impl DownloadPlan {
185 /// Metadata resolved for the remote file: size, identity headers, and
186 /// whether the server supports ranged (and therefore parallel) requests.
187 #[must_use]
188 pub const fn info(&self) -> &UrlInfo {
189 &self.inner.common().info
190 }
191
192 /// The fully-resolved configuration this plan will run with.
193 #[must_use]
194 pub const fn config(&self) -> &Config {
195 &self.inner.common().config
196 }
197
198 /// Where the finished file is intended to land.
199 ///
200 /// When `overwrite` is disabled the actual destination is uniquified at the
201 /// very end, so the file may land on a `name (1).ext` variant instead;
202 /// [`Event::Renamed`] carries the path it really landed on.
203 #[must_use]
204 pub fn final_path(&self) -> &Path {
205 &self.inner.common().final_path
206 }
207
208 /// The `.part` file this plan inspected.
209 ///
210 /// For [`ResumeOutcome::Resumable`] and [`ResumeOutcome::Mismatch`] this is
211 /// the existing file that was examined. For a normal [`ResumeOutcome::Fresh`]
212 /// it is the name that was free at probe time — because planning claims
213 /// nothing, a concurrent download can take it first, in which case starting
214 /// the plan moves on to the next free name. A manifest-driven Fresh plan
215 /// instead reports the `.part` paired with that manifest.
216 #[must_use]
217 pub fn tmp_path(&self) -> &Path {
218 &self.inner.common().tmp_path
219 }
220
221 /// The `.fd` state file paired with [`DownloadPlan::tmp_path`].
222 #[must_use]
223 pub fn config_path(&self) -> &Path {
224 &self.inner.common().cfg_path
225 }
226
227 /// What the disk probe found, and therefore what starting the plan will do.
228 #[must_use]
229 pub const fn resume_outcome(&self) -> &ResumeOutcome {
230 self.inner.outcome()
231 }
232
233 /// Run the plan as probed.
234 ///
235 /// [`ResumeOutcome::Resumable`] continues from the saved progress and
236 /// [`ResumeOutcome::Fresh`] downloads the whole file. A
237 /// [`ResumeOutcome::Mismatch`] falls back to a fresh download, except for a
238 /// plan made by [`plan_resume`], where it is reported as
239 /// [`Event::ResumeError`] and ends the run — that caller asked to continue
240 /// one specific file, not to fetch it again.
241 ///
242 /// Emits exactly one [`Event::Terminated`] as the last event on the channel.
243 pub async fn start(self) {
244 let tx = self.inner.common().tx.clone();
245 let reason = self.inner.run().await;
246 let _ = tx.send(Event::Terminated(reason));
247 }
248
249 /// Ignore any resumable state and download the whole file again.
250 ///
251 /// With `overwrite` enabled the existing `.part` is reused as scratch space
252 /// and every byte is rewritten; otherwise a new, unclaimed `.part` name is
253 /// taken so the existing one is left untouched.
254 ///
255 /// Emits exactly one [`Event::Terminated`] as the last event on the channel.
256 pub async fn start_fresh(self) {
257 let tx = self.inner.common().tx.clone();
258 let reason = self.inner.run_fresh().await;
259 let _ = tx.send(Event::Terminated(reason));
260 }
261
262 /// Continue from a [`ResumeOutcome::Mismatch`] state despite the failed
263 /// validation.
264 ///
265 /// This is only safe when the byte layout is unchanged, so the forced
266 /// resume is refused unless the mismatch is limited to the identity headers
267 /// (etag / last-modified) and both sides agree on the file size. A size
268 /// change means the recorded ranges describe different bytes, and reusing
269 /// them would splice two versions of the file together.
270 ///
271 /// The `.part` is also measured against the recorded progress: a file
272 /// shorter than the highest recorded offset is rejected with
273 /// [`StateError::Truncated`], because the sink would extend it with zeros
274 /// over the missing span and never fetch those bytes.
275 ///
276 /// A refusal is reported as [`Event::ResumeError`] followed by
277 /// [`TerminationReason::Failed`]. Called on a plan that is not a mismatch,
278 /// this behaves exactly like [`DownloadPlan::start`].
279 ///
280 /// Emits exactly one [`Event::Terminated`] as the last event on the channel.
281 pub async fn start_forced_resume(self) {
282 let tx = self.inner.common().tx.clone();
283 let reason = self.inner.run_forced().await;
284 let _ = tx.send(Event::Terminated(reason));
285 }
286}
287
288/// Turn a validated state into the public [`ResumeOutcome::Resumable`] view.
289fn resumable_outcome(state: &DownloadState, config_path: &Path) -> ResumeOutcome {
290 let progress = state.get_progress();
291 let downloaded = progress.total();
292 ResumeOutcome::Resumable {
293 config_path: config_path.to_path_buf(),
294 progress,
295 downloaded,
296 }
297}
298
299/// The common, side-effect-free front half of every plan: resolve the config,
300/// prefetch the remote metadata, and compute the output path.
301struct Probe {
302 config: Config,
303 info: UrlInfo,
304 resp: Response,
305 final_path: PathBuf,
306}
307
308#[allow(clippy::result_large_err)]
309async fn probe(url: &Url, partial_config: &PartialConfig, tx: &Tx) -> Result<Probe, PlanError> {
310 let config = partial_config.clone().build();
311 let (info, resp) = prefetch(url, &config, tx).await?;
312 let final_path = gen_path(url, &info, &config)
313 .await
314 .map_err(PlanError::GenPath)?;
315 Ok(Probe {
316 config,
317 info,
318 resp,
319 final_path,
320 })
321}
322
323/// Probe the `.fd`/`.part` pair at `cfg_path`/`tmp_path` and map the outcome onto
324/// the public [`ResumeOutcome`] view.
325async fn probe_resume(
326 url: &Url,
327 cfg_path: &Path,
328 tmp_path: &Path,
329 info: &UrlInfo,
330 partial_config: &PartialConfig,
331) -> (Option<DownloadState>, ResumeOutcome) {
332 match try_load_resume_state(url, cfg_path, tmp_path, info, partial_config).await {
333 Ok(Some(state)) => {
334 let outcome = resumable_outcome(&state, cfg_path);
335 (Some(state), outcome)
336 }
337 Ok(None) => (None, ResumeOutcome::Fresh),
338 Err(e) => (None, ResumeOutcome::Mismatch(e)),
339 }
340}
341
342/// The caller's URL when given, otherwise the durable initial URL recorded in
343/// the `.fd` state.
344fn state_url(url: Option<Url>, loaded: &DownloadState) -> Option<Url> {
345 url.or_else(|| {
346 let guard = loaded.lock_inner();
347 if let Some(url) = &guard.url
348 && matches!(url.scheme(), "http" | "https")
349 {
350 Some(url.clone())
351 } else {
352 None
353 }
354 })
355}
356
357/// Layer caller overrides over the persisted configuration before prefetching
358/// or computing paths. Persisted byte progress is intentionally excluded here:
359/// it is restored only after the current `.fd`/`.part` pair validates.
360fn inherit_persisted_config(config: &mut PartialConfig, loaded: &DownloadState) {
361 let requested_progress = config.downloaded_chunk.take();
362 let saved = loaded.lock_inner().config.clone();
363 if let Some(saved) = saved {
364 config.inherit_from(&saved);
365 }
366 config.downloaded_chunk = requested_progress;
367}
368
369fn resolved_plan_config(probe_config: Config, state: Option<&DownloadState>) -> Config {
370 let Some(state) = state else {
371 return probe_config;
372 };
373 state
374 .lock_inner()
375 .config
376 .clone()
377 .map_or(probe_config, ConfigLayer::build)
378}
379
380#[allow(clippy::too_many_arguments)]
381fn plan_common(
382 url: Url,
383 partial_config: PartialConfig,
384 probe: Probe,
385 cfg_path: PathBuf,
386 tmp_path: PathBuf,
387 tx: Tx,
388 token: CancellationToken,
389 state: Option<&DownloadState>,
390) -> PlanCommon {
391 let Probe {
392 config: probe_config,
393 info,
394 resp,
395 final_path,
396 } = probe;
397 PlanCommon {
398 url,
399 partial_config,
400 config: resolved_plan_config(probe_config, state),
401 info,
402 resp,
403 final_path,
404 cfg_path,
405 tmp_path,
406 tx,
407 token,
408 }
409}
410
411#[cfg(test)]
412mod tests;