use std::path::Path;
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
use url::Url;
use crate::app::client::http::HttpHandler;
use crate::constants::{files, limits};
use crate::errors::{DownloadError, DownloadResult};
pub struct DownloadHandler<'a> {
http_handler: &'a HttpHandler,
}
impl<'a> DownloadHandler<'a> {
pub fn new(http_handler: &'a HttpHandler) -> Self {
Self { http_handler }
}
pub async fn download_file(
&self,
url: &Url,
destination: &Path,
force: bool,
) -> DownloadResult<()> {
if destination.exists() && !force {
return Err(DownloadError::FileExists {
path: destination.display().to_string(),
});
}
if let Some(parent) = destination.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let temp_path = destination.with_extension(format!(
"{}{}",
destination
.extension()
.and_then(|s| s.to_str())
.unwrap_or(""),
files::TEMP_FILE_SUFFIX
));
let mut retries = 0;
loop {
match self.download_file_attempt(url, &temp_path).await {
Ok(()) => {
tokio::fs::rename(&temp_path, destination)
.await
.map_err(|_e| DownloadError::AtomicOperationFailed {
temp_path: temp_path.clone(),
final_path: destination.to_path_buf(),
})?;
tracing::info!("Successfully downloaded: {}", destination.display());
return Ok(());
}
Err(e) if retries < limits::MAX_RETRIES => {
retries += 1;
let delay = std::time::Duration::from_millis(
limits::RETRY_BASE_DELAY_MS * 2_u64.pow(retries),
);
tracing::warn!(
"Download failed (attempt {}/{}): {}. Retrying in {}ms",
retries,
limits::MAX_RETRIES,
e,
delay.as_millis()
);
tokio::time::sleep(delay).await;
}
Err(e) => {
if temp_path.exists() {
let _ = tokio::fs::remove_file(&temp_path).await;
}
tracing::error!(
"Download failed after {} retries: {}",
limits::MAX_RETRIES,
e
);
return Err(DownloadError::MaxRetriesExceeded {
max_retries: limits::MAX_RETRIES,
});
}
}
}
}
async fn download_file_attempt(&self, url: &Url, temp_path: &Path) -> DownloadResult<()> {
let response = self.http_handler.get_response(url).await?;
if !response.status().is_success() {
return Err(DownloadError::ServerError {
status: response.status().as_u16(),
});
}
let mut file = File::create(temp_path).await?;
let bytes = response.bytes().await?;
file.write_all(&bytes).await?;
file.flush().await?;
Ok(())
}
pub async fn download_file_content(&self, url: &str) -> DownloadResult<Vec<u8>> {
let parsed_url = Url::parse(url).map_err(|e| DownloadError::InvalidUrl {
url: url.to_string(),
error: e.to_string(),
})?;
let response = self.http_handler.get_response(&parsed_url).await?;
if !response.status().is_success() {
match response.status().as_u16() {
404 => {
return Err(DownloadError::NotFound {
url: url.to_string(),
})
}
403 => {
return Err(DownloadError::Forbidden {
url: url.to_string(),
})
}
status => return Err(DownloadError::ServerError { status }),
}
}
let bytes = response.bytes().await.map_err(DownloadError::Http)?;
Ok(bytes.to_vec())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
use tokio::fs;
use crate::app::client::config::ClientConfig;
use crate::app::client::http::HttpHandler;
async fn create_test_handler() -> HttpHandler {
let config = ClientConfig::default();
let client = config.build_http_client().unwrap();
HttpHandler::new(client, 5).unwrap()
}
#[tokio::test]
async fn test_download_file_already_exists() {
let temp_dir = tempdir().unwrap();
let file_path = temp_dir.path().join("existing_file.csv");
fs::write(&file_path, "existing content").await.unwrap();
let http_handler = create_test_handler().await;
let download_handler = DownloadHandler::new(&http_handler);
let url = Url::parse("https://example.com/test.csv").unwrap();
let result = download_handler
.download_file(&url, &file_path, false)
.await;
assert!(result.is_err());
match result.unwrap_err() {
DownloadError::FileExists { .. } => {
}
other => {
panic!("Expected DownloadError::FileExists, got {:?}", other);
}
}
}
#[tokio::test]
async fn test_temp_file_path_generation() {
let original_path = Path::new("/tmp/test.csv");
let temp_path = original_path.with_extension(format!(
"{}{}",
original_path
.extension()
.and_then(|s| s.to_str())
.unwrap_or(""),
files::TEMP_FILE_SUFFIX
));
assert!(temp_path.to_string_lossy().ends_with(".csv.tmp"));
}
#[tokio::test]
async fn test_temp_file_path_no_extension() {
let original_path = Path::new("/tmp/testfile");
let temp_path = original_path.with_extension(format!(
"{}{}",
original_path
.extension()
.and_then(|s| s.to_str())
.unwrap_or(""),
files::TEMP_FILE_SUFFIX
));
assert!(temp_path.to_string_lossy().ends_with(".tmp"));
}
#[test]
fn test_download_url_parsing() {
let valid_url = "https://example.com/file.csv";
let parsed = Url::parse(valid_url);
assert!(parsed.is_ok());
let invalid_url = "not-a-url";
let parsed = Url::parse(invalid_url);
assert!(parsed.is_err());
}
#[test]
fn test_http_status_code_mapping() {
assert_eq!(404_u16, 404); assert_eq!(403_u16, 403); assert_eq!(500_u16, 500); }
}