Skip to main content

trauma/
download.rs

1//! Represents a file to be downloaded.
2
3use crate::Error;
4use reqwest::{
5    header::{ACCEPT_RANGES, CONTENT_LENGTH},
6    StatusCode, Url,
7};
8use reqwest_middleware::ClientWithMiddleware;
9use std::convert::TryFrom;
10
11/// Represents a file to be downloaded.
12#[derive(Debug, Clone)]
13pub struct Download {
14    /// URL of the file to download.
15    pub url: Url,
16    /// File name used to save the file on disk.
17    pub filename: String,
18}
19
20impl Download {
21    /// Creates a new [`Download`].
22    ///
23    /// When using the [`Download::try_from`] method, the file name is
24    /// automatically extracted from the URL.
25    ///
26    /// ## Example
27    ///
28    /// The following calls are equivalent, minus some extra URL validations
29    /// performed by `try_from`:
30    ///
31    /// ```no_run
32    /// # use color_eyre::{eyre::Report, Result};
33    /// use trauma::download::Download;
34    /// use reqwest::Url;
35    ///
36    /// # fn main() -> Result<(), Report> {
37    /// Download::try_from("https://example.com/file-0.1.2.zip")?;
38    /// Download::new(&Url::parse("https://example.com/file-0.1.2.zip")?, "file-0.1.2.zip");
39    /// # Ok(())
40    /// # }
41    /// ```
42    pub fn new(url: &Url, filename: &str) -> Self {
43        Self {
44            url: url.clone(),
45            filename: String::from(filename),
46        }
47    }
48
49    /// Check whether the download is resumable.
50    pub async fn is_resumable(
51        &self,
52        client: &ClientWithMiddleware,
53    ) -> Result<bool, reqwest_middleware::Error> {
54        let res = client.head(self.url.clone()).send().await?;
55        let headers = res.headers();
56        match headers.get(ACCEPT_RANGES) {
57            None => Ok(false),
58            Some(x) if x == "none" => Ok(false),
59            Some(_) => Ok(true),
60        }
61    }
62
63    /// Retrieve the content_length of the download.
64    ///
65    /// Returns None if the "content-length" header is missing or if its value
66    /// is not a u64.
67    pub async fn content_length(
68        &self,
69        client: &ClientWithMiddleware,
70    ) -> Result<Option<u64>, reqwest_middleware::Error> {
71        let res = client.head(self.url.clone()).send().await?;
72        let headers = res.headers();
73        match headers.get(CONTENT_LENGTH) {
74            None => Ok(None),
75            Some(header_value) => match header_value.to_str() {
76                Ok(v) => match v.to_string().parse::<u64>() {
77                    Ok(v) => Ok(Some(v)),
78                    Err(_) => Ok(None),
79                },
80                Err(_) => Ok(None),
81            },
82        }
83    }
84}
85
86impl TryFrom<&Url> for Download {
87    type Error = crate::Error;
88
89    fn try_from(value: &Url) -> Result<Self, Self::Error> {
90        value
91            .path_segments()
92            .ok_or_else(|| {
93                Error::InvalidUrl(format!("the url \"{value}\" does not contain a valid path"))
94            })?
95            .next_back()
96            .map(String::from)
97            .map(|filename| Download {
98                url: value.clone(),
99                filename: form_urlencoded::parse(filename.as_bytes())
100                    .map(|(key, val)| [key, val].concat())
101                    .collect(),
102            })
103            .ok_or_else(|| {
104                Error::InvalidUrl(format!("the url \"{value}\" does not contain a filename"))
105            })
106    }
107}
108
109impl TryFrom<&str> for Download {
110    type Error = crate::Error;
111
112    fn try_from(value: &str) -> Result<Self, Self::Error> {
113        Url::parse(value)
114            .map_err(|e| Error::InvalidUrl(format!("the url \"{value}\" cannot be parsed: {e}")))
115            .and_then(|u| Download::try_from(&u))
116    }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum Status {
121    Fail(String),
122    NotStarted,
123    Skipped(String),
124    Success,
125}
126/// Represents a [`Download`] summary.
127#[derive(Debug, Clone)]
128pub struct Summary {
129    /// Downloaded items.
130    download: Download,
131    /// HTTP status code.
132    statuscode: StatusCode,
133    /// Download size in bytes.
134    size: u64,
135    /// Status.
136    status: Status,
137    /// Resumable.
138    resumable: bool,
139}
140
141impl Summary {
142    /// Create a new [`Download`] [`Summary`].
143    pub fn new(download: Download, statuscode: StatusCode, size: u64, resumable: bool) -> Self {
144        Self {
145            download,
146            statuscode,
147            size,
148            status: Status::NotStarted,
149            resumable,
150        }
151    }
152
153    /// Attach a status to a [`Download`] [`Summary`].
154    pub fn with_status(self, status: Status) -> Self {
155        Self { status, ..self }
156    }
157
158    /// Get the summary's status.
159    pub fn statuscode(&self) -> StatusCode {
160        self.statuscode
161    }
162
163    /// Get the summary's size.
164    pub fn size(&self) -> u64 {
165        self.size
166    }
167
168    /// Get a reference to the summary's download.
169    pub fn download(&self) -> &Download {
170        &self.download
171    }
172
173    /// Get a reference to the summary's status.
174    pub fn status(&self) -> &Status {
175        &self.status
176    }
177
178    pub fn fail(self, msg: impl std::fmt::Display) -> Self {
179        Self {
180            status: Status::Fail(format!("{msg}")),
181            ..self
182        }
183    }
184
185    /// Set the summary's resumable.
186    pub fn set_resumable(&mut self, resumable: bool) {
187        self.resumable = resumable;
188    }
189
190    /// Get the summary's resumable.
191    #[must_use]
192    pub fn resumable(&self) -> bool {
193        self.resumable
194    }
195}
196
197#[cfg(test)]
198mod test {
199    use super::*;
200
201    const DOMAIN: &str = "http://domain.com/file.zip";
202
203    #[test]
204    fn test_try_from_url() {
205        let u = Url::parse(DOMAIN).unwrap();
206        let d = Download::try_from(&u).unwrap();
207        assert_eq!(d.filename, "file.zip")
208    }
209
210    #[test]
211    fn test_try_from_string() {
212        let d = Download::try_from(DOMAIN).unwrap();
213        assert_eq!(d.filename, "file.zip")
214    }
215}