Skip to main content

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;