use std::fs::{self, File};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::Instant;
use chrono::{NaiveDate, Utc};
use reqwest::header::{HeaderMap, CONTENT_LENGTH, ETAG, IF_NONE_MATCH, LAST_MODIFIED, USER_AGENT};
use reqwest::StatusCode;
use tokio::io::AsyncWriteExt;
use tracing::{debug, info, warn};
use crate::catalog::{DataFile, ServiceCatalog};
use crate::config::DownloadConfig;
use crate::error::{DownloadError, Result};
use crate::progress::{no_progress, DownloadProgress, ProgressCallback};
pub struct FccClient {
http: reqwest::Client,
config: DownloadConfig,
}
impl FccClient {
pub fn new(config: DownloadConfig) -> Result<Self> {
fs::create_dir_all(&config.cache_dir).map_err(|_| DownloadError::CacheDirectoryError {
path: config.cache_dir.clone(),
})?;
let mut headers = HeaderMap::new();
headers.insert(
USER_AGENT,
config
.user_agent
.parse()
.unwrap_or_else(|_| crate::config::DEFAULT_USER_AGENT.parse().unwrap()),
);
let http = reqwest::Client::builder()
.timeout(config.timeout)
.default_headers(headers)
.danger_accept_invalid_certs(!config.verify_ssl)
.build()?;
Ok(Self { http, config })
}
pub fn default_client() -> Result<Self> {
Self::new(DownloadConfig::default())
}
fn url(&self, file: &DataFile) -> String {
format!("{}/{}", self.config.base_url, file.url_path())
}
fn cache_path(&self, file: &DataFile) -> PathBuf {
self.config.cache_dir.join(file.filename())
}
pub async fn download_complete(&self, service: &str) -> Result<PathBuf> {
self.download_complete_with_progress(service, no_progress())
.await
}
pub async fn download_complete_with_progress(
&self,
service: &str,
progress: ProgressCallback,
) -> Result<PathBuf> {
let file = ServiceCatalog::complete_license(service)?;
let (path, _) = self.download_file(&file, progress).await?;
Ok(path)
}
pub async fn download_applications(&self, service: &str) -> Result<PathBuf> {
let file = ServiceCatalog::complete_application(service)?;
let (path, _) = self.download_file(&file, no_progress()).await?;
Ok(path)
}
pub async fn download_all_daily(&self, service: &str) -> Result<Vec<PathBuf>> {
let files = ServiceCatalog::daily_licenses(service)?;
let mut paths = Vec::new();
for file in files {
match self.download_file(&file, no_progress()).await {
Ok((path, _)) => paths.push(path),
Err(DownloadError::NotFound { .. }) => {
debug!("Daily file not available: {}", file.filename());
}
Err(e) => return Err(e),
}
}
Ok(paths)
}
pub async fn download_daily_for_date(&self, service: &str, date: NaiveDate) -> Result<PathBuf> {
let file = ServiceCatalog::daily_license_for_date(service, date)?;
let (path, _) = self.download_file(&file, no_progress()).await?;
Ok(path)
}
pub async fn download_file(
&self,
file: &DataFile,
progress: ProgressCallback,
) -> Result<(PathBuf, DownloadResult)> {
let url = self.url(file);
let dest_path = self.cache_path(file);
let cache_exists = dest_path.exists();
let cached_etag = if cache_exists {
self.get_cached_etag(file)
} else {
None };
if cache_exists {
if let Some(ref local_etag) = cached_etag {
info!("Checking if cached {} is current...", file.filename());
let head_response = self.http.head(&url).send().await?;
if head_response.status().is_success() {
let remote_etag = head_response
.headers()
.get(ETAG)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
if let Some(ref remote) = remote_etag {
if remote == local_etag {
info!("Cache is current (ETag match): {}", file.filename());
return Ok((dest_path, DownloadResult::NotModified));
} else {
info!("Remote ETag differs, downloading new version");
}
}
}
}
}
info!("Downloading {} to {}", url, dest_path.display());
for attempt in 0..=self.config.max_retries {
if attempt > 0 {
warn!("Retry attempt {} for {}", attempt, file.filename());
tokio::time::sleep(self.config.retry_delay).await;
}
match self
.do_download(&url, &dest_path, cached_etag.as_deref(), progress.clone())
.await
{
Ok(DownloadResult::Downloaded) => {
info!("Downloaded: {}", file.filename());
return Ok((dest_path, DownloadResult::Downloaded));
}
Ok(DownloadResult::NotModified) => {
info!("Not modified (using cache): {}", file.filename());
return Ok((dest_path, DownloadResult::NotModified));
}
Err(DownloadError::NotFound { .. }) => {
return Err(DownloadError::NotFound { url });
}
Err(e) if attempt == self.config.max_retries => {
return Err(e);
}
Err(e) => {
warn!("Download attempt failed: {}", e);
}
}
}
unreachable!()
}
async fn do_download(
&self,
url: &str,
dest_path: &Path,
etag: Option<&str>,
progress: ProgressCallback,
) -> Result<DownloadResult> {
let mut request = self.http.get(url);
if let Some(etag) = etag {
request = request.header(IF_NONE_MATCH, etag);
}
let response = request.send().await?;
let status = response.status();
match status {
StatusCode::OK => {
let total_bytes = response
.headers()
.get(CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse().ok());
let new_etag = response
.headers()
.get(ETAG)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let last_modified = response
.headers()
.get(LAST_MODIFIED)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
if let Some(parent) = dest_path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = tokio::fs::File::create(dest_path).await?;
let filename = dest_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let mut prog = DownloadProgress::new(&filename, total_bytes);
let mut stream = response.bytes_stream();
let start_time = Instant::now();
use futures_util::StreamExt;
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result?;
file.write_all(&chunk).await?;
prog.downloaded_bytes += chunk.len() as u64;
let elapsed = start_time.elapsed().as_secs_f64();
if elapsed > 0.0 {
prog.speed_bps = (prog.downloaded_bytes as f64 / elapsed) as u64;
if let Some(total) = total_bytes {
let remaining = total.saturating_sub(prog.downloaded_bytes);
prog.eta_seconds =
Some((remaining as f64 / prog.speed_bps.max(1) as f64) as u64);
}
}
progress(&prog);
}
file.flush().await?;
if let Some(expected) = total_bytes {
if prog.downloaded_bytes != expected {
return Err(DownloadError::IncompleteDownload {
expected,
actual: prog.downloaded_bytes,
});
}
}
if let Some(etag) = new_etag {
self.save_etag(dest_path, &etag)?;
}
if let Some(modified) = last_modified {
debug!("Last-Modified: {}", modified);
}
Ok(DownloadResult::Downloaded)
}
StatusCode::NOT_MODIFIED => Ok(DownloadResult::NotModified),
StatusCode::NOT_FOUND => Err(DownloadError::NotFound {
url: url.to_string(),
}),
_ => Err(DownloadError::ServerError {
status: status.as_u16(),
url: url.to_string(),
}),
}
}
pub async fn check_for_updates(&self, service: &str) -> Result<UpdateInfo> {
let file = ServiceCatalog::complete_license(service)?;
let url = self.url(&file);
let response = self.http.head(&url).send().await?;
if !response.status().is_success() {
return Err(DownloadError::ServerError {
status: response.status().as_u16(),
url,
});
}
let content_length = response
.headers()
.get(CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse().ok());
let last_modified = response
.headers()
.get(LAST_MODIFIED)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let etag = response
.headers()
.get(ETAG)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let cached_etag = self.get_cached_etag(&file);
let has_update = match (&etag, &cached_etag) {
(Some(remote), Some(local)) => remote != local,
_ => true, };
Ok(UpdateInfo {
file,
size_bytes: content_length,
last_modified,
etag,
has_update,
checked_at: Utc::now(),
})
}
pub fn get_cached_etag(&self, file: &DataFile) -> Option<String> {
let etag_path = self
.config
.cache_dir
.join(format!("{}.etag", file.filename()));
fs::read_to_string(etag_path).ok()
}
fn save_etag(&self, file_path: &Path, etag: &str) -> Result<()> {
let etag_path = file_path.with_extension("zip.etag");
let mut file = File::create(etag_path)?;
file.write_all(etag.as_bytes())?;
Ok(())
}
pub fn get_cached_file_date(&self, file: &DataFile) -> Option<chrono::DateTime<Utc>> {
let path = self.cache_path(file);
std::fs::metadata(&path)
.ok()
.and_then(|m| m.modified().ok())
.map(chrono::DateTime::<Utc>::from)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DownloadResult {
Downloaded,
NotModified,
}
#[derive(Debug, Clone)]
pub struct UpdateInfo {
pub file: DataFile,
pub size_bytes: Option<u64>,
pub last_modified: Option<String>,
pub etag: Option<String>,
pub has_update: bool,
pub checked_at: chrono::DateTime<Utc>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::Weekday;
use tempfile::TempDir;
#[test]
fn test_url_generation() {
let temp_dir = TempDir::new().unwrap();
let config = DownloadConfig::with_cache_dir(temp_dir.path().to_path_buf());
let client = FccClient::new(config).unwrap();
let file = DataFile::complete_license("amat");
assert!(client.url(&file).ends_with("/complete/l_amat.zip"));
let daily = DataFile::daily_license("amat", Weekday::Monday);
assert!(client.url(&daily).ends_with("/daily/l_am_mon.zip"));
}
#[test]
fn test_cache_path() {
let temp_dir = TempDir::new().unwrap();
let config = DownloadConfig::with_cache_dir(temp_dir.path().to_path_buf());
let client = FccClient::new(config).unwrap();
let file = DataFile::complete_license("amat");
assert_eq!(client.cache_path(&file), temp_dir.path().join("l_amat.zip"));
}
}