use std::{
cmp::min,
error::Error,
fs::File,
io::{BufWriter, Read, Write},
path::PathBuf,
time::{Duration, Instant},
};
use crate::{
filename_handling,
hasher::{Algorithm, Hasher},
os_specifics::OS,
term_output, utils,
};
use anyhow::Result;
use ureq::{ResponseExt, config::Config, http::header::*};
use indicatif::{ProgressBar, ProgressStyle};
const CONNECTION_TIMEOUT: Duration = Duration::from_secs(25);
#[derive(Debug, Clone)]
struct DownloadError {
err_msg: String,
}
impl DownloadError {
fn new(err_msg: String) -> Self {
Self { err_msg }
}
}
impl Error for DownloadError {}
impl std::fmt::Display for DownloadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Download canceled | {}", self.err_msg)
}
}
#[derive(Debug)]
pub struct DownloadProperties {
pub algorithm: Algorithm,
pub url: String,
pub output_target: PathBuf,
pub default_file_name: Option<String>,
pub os_type: OS,
}
#[derive(Debug)]
pub struct DownloadResult {
pub file_location: PathBuf,
pub hash_sum: String,
}
#[derive(Debug)]
enum FileSizeState {
Known(usize),
Unknown,
Chunked,
}
pub fn execute_download(download_properties: DownloadProperties) -> Result<DownloadResult> {
let spinner = ProgressBar::new_spinner()
.with_message(format!(
"Connection establishment... Timeout: {}s",
CONNECTION_TIMEOUT.as_secs()
))
.with_position(25);
spinner.set_style(
ProgressStyle::default_spinner()
.tick_strings(&term_output::BOUNCING_BAR)
.template("{spinner:.white} {msg}")
.unwrap_or_else(|_| ProgressStyle::default_spinner()),
);
spinner.enable_steady_tick(Duration::from_millis(100));
let http_agent = Config::builder()
.http_status_as_error(true)
.save_redirect_history(true)
.timeout_connect(Some(CONNECTION_TIMEOUT))
.build()
.new_agent();
let response = match http_agent.get(&download_properties.url).call() {
Ok(response) => {
spinner.finish_and_clear();
response
}
Err(response_err) => {
spinner.finish_and_clear();
let err_msg = format!("Failed to establish connection to the server [{response_err}]");
let download_err = DownloadError::new(err_msg);
log::error!("{download_err}");
return Err(download_err.into());
}
};
let file_size_state = determine_file_size_state(response.headers());
if let FileSizeState::Unknown = file_size_state {
let err_description = "The server response did not contain any information on how to handle the file size of the file to be downloaded. \
Please check the server or try to download the file from another source.";
let download_err = DownloadError::new(err_description.to_string());
log::error!("{download_err}");
Err(download_err.into())
} else {
let uri = response.get_uri().to_string();
let content_disposition = response
.headers()
.get(CONTENT_DISPOSITION)
.map_or("", |header_value| header_value.to_str().unwrap_or_default());
let extract_result = match download_properties.default_file_name {
Some(default_file_name) => Some(default_file_name),
None => {
utils::extract_file_name(&uri, content_disposition, &download_properties.os_type)
}
};
let filename = match extract_result {
Some(filename) => filename,
None => {
println!("Could not determine a filename from server response");
println!("Please enter a name for the file to be downloaded");
filename_handling::enter_and_verify_file_name(&download_properties.os_type)?
}
};
let file_path = download_properties.output_target.join(filename);
let body_reader = response.into_body().into_reader();
make_download_req(
file_path,
body_reader,
file_size_state,
download_properties.algorithm,
)
}
}
fn make_download_req(
file_path: PathBuf,
mut body_reader: impl Read,
file_size_state: FileSizeState,
algorithm: Algorithm,
) -> Result<DownloadResult> {
let file = File::create(&file_path).map_err(|io_err| {
let msg = format!(
"Failed to create file: {}",
utils::absolute_path_as_string(&file_path),
);
let download_err = DownloadError::new(msg);
log::error!("{download_err} - Details: {io_err:?}");
download_err
})?;
log::info!(
"Start download - Total file size: {}",
match file_size_state {
FileSizeState::Known(total_size) => utils::convert_bytes_to_human_readable(total_size),
_ => "unknown".to_string(),
}
);
log::info!(
"Output target: {}",
utils::absolute_path_as_string(&file_path)
);
let progress_bar = match file_size_state {
FileSizeState::Known(total_size) => {
let pb = ProgressBar::new(total_size as u64);
pb.set_style(
ProgressStyle::with_template(
"[{msg}] [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({eta})",
)
.unwrap_or(ProgressStyle::default_bar())
.progress_chars("#>-"),
);
pb.set_message("Download in progress");
pb
}
_ => {
let spinner = ProgressBar::new_spinner();
spinner.set_style(
ProgressStyle::default_spinner()
.tick_strings(&term_output::BOUNCING_BAR)
.template("{spinner:.white} {msg}")
.unwrap_or_else(|_| ProgressStyle::default_spinner()),
);
spinner.enable_steady_tick(Duration::from_millis(100));
spinner
}
};
let mut writer = BufWriter::with_capacity(utils::CAPACITY, file);
let mut buffer = [0u8; utils::CAPACITY];
let mut downloaded_bytes: usize = 0;
let mut hasher = Hasher::new(algorithm);
let start = Instant::now();
let download_result = loop {
match body_reader.read(&mut buffer) {
Ok(bytes_read) => {
if bytes_read == 0 {
break Ok(downloaded_bytes);
}
hasher.update(&buffer[..bytes_read]);
writer
.write_all(&buffer[..bytes_read])
.map_err(|write_err| {
let download_err = DownloadError::new(format!(
"Unable to write data from server response into file: {}",
utils::absolute_path_as_string(&file_path),
));
log::error!("{download_err} - Details: {write_err:?}");
download_err
})?;
downloaded_bytes += bytes_read;
match file_size_state {
FileSizeState::Known(total_size) => {
let pb_value = min(downloaded_bytes, total_size);
progress_bar.set_position(pb_value as u64);
}
_ => {
progress_bar.set_message(format!(
"Download in progress... {}",
utils::convert_bytes_to_human_readable(downloaded_bytes)
));
}
}
}
Err(body_access_err) => {
let download_err =
DownloadError::new("Failed to read data from server response".to_string());
log::error!("{download_err} - Details: {body_access_err:?}");
break Err(download_err);
}
}
};
let end = Instant::now();
progress_bar.finish_and_clear();
let written_bytes = download_result?;
log::info!(
"Download finished - Processed file size: {}",
utils::convert_bytes_to_human_readable(written_bytes)
);
let total_duration = end - start;
println!(
"\nDownload done in : {}",
utils::calc_duration(total_duration.as_secs())
);
Ok(DownloadResult {
file_location: file_path,
hash_sum: hex::encode(hasher.finalize()),
})
}
fn determine_file_size_state(headers: &HeaderMap) -> FileSizeState {
{
let file_size_state = get_content_length(headers);
if let FileSizeState::Unknown = file_size_state {
let file_size_state = get_content_range(headers);
if let FileSizeState::Unknown = file_size_state {
get_transfer_encoding(headers)
} else {
file_size_state
}
} else {
file_size_state
}
}
}
fn get_content_length(headers: &HeaderMap) -> FileSizeState {
headers.get(CONTENT_LENGTH).map_or(FileSizeState::Unknown, |header_value| {
match header_value.to_str() {
Ok(value) => {
match value.parse::<usize>() {
Ok(total_size) => {
if total_size > 0 {
FileSizeState::Known(total_size)
} else {
log::error!(
"The server response contains an invalid value for the file size. It can not be zero - {CONTENT_LENGTH}: {total_size}"
);
FileSizeState::Unknown
}
}
Err(parse_err) => {
log::error!("The server response contains an invalid value for the file size.");
log::error!(
"{CONTENT_LENGTH}: {value} --> {parse_err}"
);
FileSizeState::Unknown
}
}
}
Err(_) => FileSizeState::Unknown,
}
})
}
fn get_content_range(headers: &HeaderMap) -> FileSizeState {
headers.get(CONTENT_RANGE).map_or(FileSizeState::Unknown, |header_value| {
match header_value.to_str() {
Ok(value) => {
match value.split('/').next_back() {
Some(total_size) => {
if total_size.contains("*") {
FileSizeState::Chunked
} else {
match total_size.parse::<usize>() {
Ok(total_size) => {
if total_size > 0 {
FileSizeState::Known(total_size)
} else {
log::error!(
"The server response contains an invalid value for the file size. It can not be zero - {CONTENT_RANGE}: {total_size}"
);
FileSizeState::Unknown
}
}
Err(parse_err) => {
log::error!("The server response contains an invalid value for the file size.");
log::error!(
"{CONTENT_RANGE}: {total_size} --> {parse_err}"
);
FileSizeState::Unknown
}
}
}
}
None => FileSizeState::Unknown,
}
}
Err(_) => FileSizeState::Unknown,
}
})
}
fn get_transfer_encoding(headers: &HeaderMap) -> FileSizeState {
headers
.get(TRANSFER_ENCODING)
.map_or(FileSizeState::Unknown, |header_value| {
match header_value.to_str() {
Ok(value) => {
if value.contains("chunked") {
FileSizeState::Chunked
} else {
FileSizeState::Unknown
}
}
Err(_) => FileSizeState::Unknown,
}
})
}