Skip to main content

nifty_download/
report.rs

1//! What a download tells the app, and what each report writes into the state it bound.
2
3use std::path::PathBuf;
4
5use nifty_runtime::DownloadField;
6
7use crate::id::DownloadId;
8
9/// What `status` shows from the click until the download finishes.
10const DOWNLOADING: &str = "Downloading…";
11/// What `status` shows while the download waits to try a failed connection again.
12const RETRYING: &str = "Retrying…";
13/// What `status` shows while the download is paused.
14const PAUSED: &str = "Paused";
15/// What `status` shows once code cancels the download.
16const CANCELLED: &str = "Cancelled";
17const DONE_PERCENT: i64 = 100;
18
19/// How far a download has got, sent to the UI thread.
20#[derive(Debug, PartialEq, Eq)]
21pub enum Report {
22    /// The call was just made, giving the download this id: everything starts from
23    /// nothing.
24    Started(DownloadId),
25    Progress(Progress),
26    /// Code paused it, with this much saved.
27    Paused(Progress),
28    /// A connection failed in a way that may pass, and the download waits to try again.
29    Retrying,
30    /// It is downloading again, after a pause or a wait to retry.
31    Downloading,
32    /// The file is saved at `path`, `size` bytes long.
33    Saved {
34        path: PathBuf,
35        size: u64,
36    },
37    Failed(String),
38    /// Code cancelled it, and what it had saved is removed.
39    Cancelled,
40}
41
42/// The bytes received so far, of `total` when the server said, at `speed` bytes a second.
43#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
44pub struct Progress {
45    pub received: u64,
46    pub total: Option<u64>,
47    pub speed: u64,
48}
49
50/// A value a report writes: a whole number or text, as its field holds.
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub enum Written {
53    Int(i64),
54    Text(String),
55}
56
57impl Report {
58    /// The fields this report changes, with their new values. A field the call didn't
59    /// bind is skipped by whoever applies them.
60    #[must_use]
61    pub fn writes(&self) -> Vec<(DownloadField, Written)> {
62        use DownloadField::{Id, Progress as Percent, Received, Speed, Status, Total};
63        let int = |field, value: u64| (field, Written::Int(saturating_int(value)));
64        match self {
65            Self::Started(id) => vec![
66                (Id, Written::Text(id.to_string())),
67                (Percent, Written::Int(0)),
68                (Status, Written::Text(DOWNLOADING.to_owned())),
69                int(Received, 0),
70                int(Total, 0),
71                int(Speed, 0),
72            ],
73            Self::Paused(progress) => {
74                let mut writes = Self::Progress(*progress).writes();
75                writes.push((Status, Written::Text(PAUSED.to_owned())));
76                writes
77            }
78            Self::Retrying => vec![(Status, Written::Text(RETRYING.to_owned())), int(Speed, 0)],
79            Self::Downloading => vec![(Status, Written::Text(DOWNLOADING.to_owned()))],
80            Self::Progress(progress) => {
81                let mut writes = vec![int(Received, progress.received), int(Speed, progress.speed)];
82                if let Some(total) = progress.total {
83                    writes.push((Percent, Written::Int(percent(progress.received, total))));
84                    writes.push(int(Total, total));
85                }
86                writes
87            }
88            Self::Saved { path, size } => vec![
89                (Percent, Written::Int(DONE_PERCENT)),
90                (
91                    Status,
92                    Written::Text(format!("Saved to {}", path.display())),
93                ),
94                int(Received, *size),
95                int(Total, *size),
96                int(Speed, 0),
97            ],
98            Self::Failed(reason) => vec![
99                (Status, Written::Text(format!("Failed: {reason}"))),
100                int(Speed, 0),
101            ],
102            Self::Cancelled => vec![(Status, Written::Text(CANCELLED.to_owned())), int(Speed, 0)],
103        }
104    }
105
106    pub(crate) fn is_final(&self) -> bool {
107        matches!(self, Self::Saved { .. } | Self::Failed(_) | Self::Cancelled)
108    }
109}
110
111/// A byte count as an `int`; no file comes near the largest one.
112fn saturating_int(bytes: u64) -> i64 {
113    i64::try_from(bytes).unwrap_or(i64::MAX)
114}
115
116/// The whole percentage `received` is of `total`.
117fn percent(received: u64, total: u64) -> i64 {
118    if total == 0 {
119        return DONE_PERCENT;
120    }
121    let percent = u128::from(received.min(total)) * 100 / u128::from(total);
122    i64::try_from(percent).expect("a percentage of at most 100 fits an i64")
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn counts_whole_percentages() {
131        assert_eq!(percent(0, 200), 0);
132        assert_eq!(percent(199, 200), 99);
133        assert_eq!(percent(200, 200), 100);
134        assert_eq!(percent(300, 200), 100);
135        assert_eq!(percent(0, 0), 100);
136        assert_eq!(percent(u64::MAX - 1, u64::MAX), 99);
137    }
138
139    #[test]
140    fn progress_only_moves_the_percentage_once_the_size_is_known() {
141        let unknown = Report::Progress(Progress {
142            received: 10,
143            total: None,
144            speed: 5,
145        });
146        assert_eq!(
147            unknown.writes(),
148            [
149                (DownloadField::Received, Written::Int(10)),
150                (DownloadField::Speed, Written::Int(5)),
151            ]
152        );
153        let known = Report::Progress(Progress {
154            received: 10,
155            total: Some(40),
156            speed: 5,
157        });
158        assert!(
159            known
160                .writes()
161                .contains(&(DownloadField::Progress, Written::Int(25)))
162        );
163        assert!(
164            known
165                .writes()
166                .contains(&(DownloadField::Total, Written::Int(40)))
167        );
168    }
169
170    #[test]
171    fn saving_fills_in_the_size_and_stops_the_speed() {
172        let saved = Report::Saved {
173            path: PathBuf::from("/x/a.zip"),
174            size: 7,
175        };
176        let writes = saved.writes();
177        for expected in [
178            (DownloadField::Progress, Written::Int(100)),
179            (DownloadField::Received, Written::Int(7)),
180            (DownloadField::Total, Written::Int(7)),
181            (DownloadField::Speed, Written::Int(0)),
182        ] {
183            assert!(writes.contains(&expected), "{writes:?}");
184        }
185    }
186}