use crate::{Config, PartialConfig};
use fast_down::{FileId, ProgressEntry, UrlInfo};
use inherit_config::{ConfigLayer, InheritConfig};
use parking_lot::Mutex;
use path_helper::tokio::safe_replace;
use reqwest::Response;
use std::{
path::{Path, PathBuf},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
use tokio::fs;
use url::Url;
#[derive(Debug, thiserror::Error)]
#[allow(clippy::large_enum_variant)]
pub enum StateError {
#[error("failed to open .fd state file: {0}")]
Open(std::io::Error),
#[error("failed to save .fd state file: {0}")]
Save(std::io::Error),
#[error("failed to decode .fd state file: {0}")]
Decode(#[from] toml::de::Error),
#[error("failed to encode .fd state file: {0}")]
Encode(#[from] toml::ser::Error),
#[error(
"remote file changed, cannot resume\n local: size={local_file_size}, id={local_file_id:?}\n remote: size={remote_file_size}, id={remote_file_id:?}"
)]
FileChanged {
local_file_id: FileId,
local_file_size: u64,
remote_file_id: FileId,
remote_file_size: u64,
},
#[error(
"the .part file was truncated: it holds {actual_size} bytes but the .fd state records progress up to {recorded_size}"
)]
Truncated {
actual_size: u64,
recorded_size: u64,
},
#[error(
"server does not support resumable download\n url_info: {:?}\n url: {}\n status: {}\n headers: {:?}",
.0, .1.url(), .1.status(), .1.headers()
)]
NotResumable(UrlInfo, Response),
#[error(
"cannot resume without a URL: none was supplied and the .fd state has none (tmp_path = {0})"
)]
NoUrl(PathBuf),
#[error("peek_resume requires a .part file, got: {0}")]
NotAPartFile(PathBuf),
}
#[derive(Debug, Clone, InheritConfig)]
pub struct DownloadStateInner {
#[config(default = Url::parse("about:blank").unwrap())]
pub url: Url,
pub etag: Option<Arc<str>>,
pub last_modified: Option<Arc<str>>,
#[config(nest)]
pub config: Config,
pub size: u64,
#[config(default = Duration::ZERO)]
#[config(partial_attr(serde(with = "humantime_serde::option")))]
#[config(partial_attr(serde(default)))]
pub elapsed: Duration,
}
#[derive(Debug, Clone)]
pub struct DownloadState {
inner: Arc<Mutex<PartialDownloadStateInner>>,
is_dirty: Arc<AtomicBool>,
pub config_path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct ResumeInfo {
pub url: Option<Url>,
pub size: u64,
pub progress: Vec<ProgressEntry>,
pub elapsed: Duration,
}
impl DownloadState {
pub fn lock_inner(&self) -> parking_lot::MutexGuard<'_, PartialDownloadStateInner> {
self.inner.lock()
}
#[must_use]
pub fn share_inner(&self) -> Arc<Mutex<PartialDownloadStateInner>> {
self.inner.clone()
}
#[must_use]
pub fn new(url: &Url, url_info: &UrlInfo, config: &PartialConfig, config_path: &Path) -> Self {
Self {
inner: Arc::new(Mutex::new(PartialDownloadStateInner {
url: Some(url.clone()),
etag: Some(url_info.file_id.etag.clone()),
last_modified: Some(url_info.file_id.last_modified.clone()),
config: Some(config.clone()),
size: Some(url_info.size),
elapsed: Some(Duration::ZERO),
})),
is_dirty: Arc::new(AtomicBool::new(true)),
config_path: config_path.to_path_buf(),
}
}
#[allow(clippy::result_large_err)]
pub async fn load(config_path: &Path) -> Result<Self, StateError> {
let inner = fs::read(&config_path).await.map_err(StateError::Open)?;
let inner: PartialDownloadStateInner = toml::from_slice(&inner)?;
Ok(Self {
inner: Arc::new(Mutex::new(inner)),
is_dirty: Arc::new(AtomicBool::new(false)),
config_path: config_path.to_path_buf(),
})
}
#[allow(clippy::result_large_err)]
pub async fn store(&self) -> Result<(), StateError> {
let serialized = {
let mut guard = self.inner.lock();
guard.simplify_from(&PartialDownloadStateInner::default());
toml::to_string_pretty(&*guard)?
};
safe_replace(&self.config_path, serialized.as_bytes())
.await
.map_err(StateError::Save)?;
Ok(())
}
#[must_use]
pub fn take_dirty(&self) -> bool {
self.is_dirty.swap(false, Ordering::SeqCst)
}
pub fn mark_dirty(&self) {
self.is_dirty.store(true, Ordering::SeqCst);
}
pub fn update<F: FnOnce(&mut PartialDownloadStateInner) -> R, R>(&self, cb: F) -> R {
let res = cb(&mut self.inner.lock());
self.mark_dirty();
res
}
#[allow(clippy::result_large_err)]
pub fn validate(&self, info: &UrlInfo) -> Result<(), StateError> {
let inner = self.inner.lock();
let local_file_id = FileId {
etag: inner.etag.clone().flatten(),
last_modified: inner.last_modified.clone().flatten(),
};
let local_file_size = inner.size.unwrap_or(0);
drop(inner);
let is_same = local_file_size == info.size && local_file_id == info.file_id;
if is_same {
Ok(())
} else {
Err(StateError::FileChanged {
local_file_id,
local_file_size,
remote_file_id: info.file_id.clone(),
remote_file_size: info.size,
})
}
}
#[must_use]
pub fn get_progress(&self) -> Vec<ProgressEntry> {
self.inner
.lock()
.config
.as_ref()
.and_then(|c| c.downloaded_chunk.clone())
.unwrap_or_default()
}
#[must_use]
pub fn get_elapsed(&self) -> Duration {
self.inner.lock().elapsed.unwrap_or(Duration::ZERO)
}
#[must_use]
pub fn snapshot(&self) -> ResumeInfo {
let guard = self.inner.lock();
ResumeInfo {
url: guard.url.clone(),
size: guard.size.unwrap_or(0),
progress: guard
.config
.as_ref()
.and_then(|c| c.downloaded_chunk.clone())
.unwrap_or_default(),
elapsed: guard.elapsed.unwrap_or(Duration::ZERO),
}
}
pub fn set_elapsed(&self, elapsed: Duration) {
self.update(|inner| inner.elapsed = Some(elapsed));
}
pub fn merge_progress(&self, range: ProgressEntry) {
self.update(|inner| inner.config.get_or_insert_default().merge_progress(range));
}
pub fn merge_config(&self, partial_config: &PartialConfig) {
self.update(|inner| {
let mut pc = partial_config.clone();
if let Some(config) = &inner.config {
if let Some(downloaded_chunk) = &config.downloaded_chunk {
for i in downloaded_chunk {
pc.merge_progress(i.clone());
}
}
pc.inherit_from(config);
}
inner.config = Some(pc);
});
}
pub fn refresh_identity(&self, url: &Url, info: &UrlInfo) {
self.update(|inner| {
inner.url = Some(url.clone());
inner.etag = Some(info.file_id.etag.clone());
inner.last_modified = Some(info.file_id.last_modified.clone());
inner.size = Some(info.size);
});
}
#[must_use]
pub fn file_id(&self) -> FileId {
let inner = self.inner.lock();
FileId {
etag: inner.etag.clone().flatten(),
last_modified: inner.last_modified.clone().flatten(),
}
}
#[must_use]
pub fn tmp_path(&self) -> PathBuf {
self.config_path.with_extension("part")
}
pub async fn part_shortfall(&self, tmp_path: &Path) -> Option<(u64, u64)> {
let metadata = fs::metadata(tmp_path).await.ok()?;
if !metadata.is_file() {
return None;
}
let actual_size = metadata.len();
let recorded_size = self.get_progress().last().map_or(0, |r| r.end);
(actual_size < recorded_size).then_some((actual_size, recorded_size))
}
}
#[cfg(test)]
mod tests {
use super::{DownloadState, PartialConfig, StateError};
use fast_down::{FileId, UrlInfo};
use std::path::Path;
use std::time::Duration;
use url::Url;
fn make_state(path: &Path) -> DownloadState {
let url = Url::parse("https://example.com/file.bin").unwrap();
let url_info = UrlInfo {
size: 1024,
raw_name: "file.bin".to_string(),
supports_range: true,
fast_download: true,
final_url: url.clone(),
file_id: FileId::new(Some("etag-1"), None),
content_type: Some("application/octet-stream".to_string()),
};
DownloadState::new(&url, &url_info, &PartialConfig::default(), path)
}
#[tokio::test]
async fn part_shortfall_reports_short_file_against_recorded_frontier() {
let dir = std::env::temp_dir().join(format!("fd_ps_short_{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let part = dir.join("a.part");
let state = make_state(Path::new("dummy.fd"));
state.merge_progress(0u64..600);
std::fs::write(&part, vec![0u8; 500]).unwrap();
assert_eq!(state.part_shortfall(&part).await, Some((500, 600)));
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn part_shortfall_is_none_when_file_covers_frontier() {
let dir = std::env::temp_dir().join(format!("fd_ps_ok_{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let part = dir.join("a.part");
let state = make_state(Path::new("dummy.fd"));
state.merge_progress(0u64..600);
std::fs::write(&part, vec![0u8; 700]).unwrap();
assert_eq!(state.part_shortfall(&part).await, None);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn elapsed_stored_as_human_readable_string_and_round_trips() {
let path = std::env::temp_dir().join(format!(
"fd_elapsed_test_{}.fd",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let state = make_state(&path);
let value = Duration::from_secs(3661); state.set_elapsed(value);
state.store().await.unwrap();
let content = tokio::fs::read_to_string(&path).await.unwrap();
assert!(
content.contains("elapsed = \"")
&& content.contains('h')
&& content.contains('m')
&& content.contains('s'),
"elapsed should be stored as a human-readable string, got:\n{content}"
);
assert!(
!content.contains("3661000000000"),
"elapsed must not be stored as raw nanoseconds, got:\n{content}"
);
let loaded = DownloadState::load(&path).await.unwrap();
assert_eq!(loaded.get_elapsed(), value);
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn elapsed_zero_is_not_persisted() {
let path = std::env::temp_dir().join(format!(
"fd_elapsed_zero_{}.fd",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let state = make_state(&path); state.store().await.unwrap();
let content = tokio::fs::read_to_string(&path).await.unwrap();
assert!(
!content.contains("elapsed"),
"a zero elapsed should be omitted from the .fd file, got:\n{content}"
);
let loaded = DownloadState::load(&path).await.unwrap();
assert_eq!(loaded.get_elapsed(), Duration::ZERO);
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn merge_config_adopts_fresh_config_when_loaded_state_has_none() {
let path = std::env::temp_dir().join(format!(
"fd_merge_none_{}.fd",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
tokio::fs::write(
&path,
"url = \"https://example.com/file.bin\"\nsize = 1024\n",
)
.await
.unwrap();
let state = DownloadState::load(&path).await.unwrap();
assert!(
state.lock_inner().config.is_none(),
"precondition: a .fd without [config] must load with inner.config = None"
);
let fresh = PartialConfig {
min_chunk_size: Some(1234),
..Default::default()
};
state.merge_config(&fresh);
assert!(
state.lock_inner().config.is_some(),
"merge_config must adopt the fresh config even when the loaded state had none \
(regression: it used to be a silent no-op that dropped the resume request)"
);
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
#[allow(clippy::single_range_in_vec_init)]
async fn merge_config_preserves_loaded_progress_into_fresh() {
let path = std::env::temp_dir().join(format!(
"fd_merge_progress_{}.fd",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let state = make_state(&path);
state.inner.lock().config = Some(PartialConfig {
downloaded_chunk: Some(vec![0u64..10, 10..20]),
..Default::default()
});
let fresh = PartialConfig {
min_chunk_size: Some(2048),
..Default::default()
};
state.merge_config(&fresh);
let merged = state.inner.lock().config.clone().unwrap();
assert_eq!(
merged.downloaded_chunk,
Some(vec![0u64..20]),
"loaded progress must be preserved and merged into the fresh config"
);
assert_eq!(
merged.min_chunk_size,
Some(2048),
"fresh request overrides must still be applied via inherit_from"
);
let _ = std::fs::remove_file(&path);
}
fn info_with(etag: Option<&str>, size: u64) -> UrlInfo {
UrlInfo {
size,
raw_name: "file.bin".to_string(),
supports_range: true,
fast_download: true,
final_url: Url::parse("https://example.com/file.bin").unwrap(),
file_id: FileId::new(etag, None),
content_type: Some("application/octet-stream".to_string()),
}
}
fn fd_path(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"fd_validate_{name}_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
))
}
#[test]
fn validate_ok_when_size_and_file_id_match() {
let state = make_state(&fd_path("ok"));
assert!(
state.validate(&info_with(Some("etag-1"), 1024)).is_ok(),
"matching size + etag must validate"
);
}
#[test]
fn validate_rejects_size_mismatch() {
let state = make_state(&fd_path("size"));
assert!(matches!(
state.validate(&info_with(Some("etag-1"), 2048)),
Err(StateError::FileChanged { .. })
));
}
#[test]
fn validate_rejects_etag_mismatch() {
let state = make_state(&fd_path("etag"));
assert!(matches!(
state.validate(&info_with(Some("etag-2"), 1024)),
Err(StateError::FileChanged { .. })
));
}
#[test]
fn refresh_identity_updates_url_to_caller_initial_url_not_final_url() {
let path = fd_path("refresh_identity");
let state = make_state(&path); assert_eq!(
state.lock_inner().url.as_ref().unwrap().as_str(),
"https://example.com/file.bin",
"precondition: fresh state records the initial caller URL"
);
let new_initial_url =
Url::parse("https://example.com/v2/signed-token-xyz/file.bin").unwrap();
let new_info = UrlInfo {
size: 2048,
raw_name: "file.bin".to_string(),
supports_range: true,
fast_download: true,
final_url: Url::parse("https://cdn.example.com/signed/token-abc/file.bin").unwrap(),
file_id: FileId::new(Some("etag-2"), Some("Mon, 02 Aug 2026 00:00:00 GMT")),
content_type: Some("application/octet-stream".to_string()),
};
state.refresh_identity(&new_initial_url, &new_info);
let inner = state.lock_inner();
assert_eq!(
inner.url.as_ref().unwrap().as_str(),
"https://example.com/v2/signed-token-xyz/file.bin",
"refresh must update the .fd URL to the caller-supplied initial URL (priority)"
);
assert_ne!(
inner.url.as_ref().unwrap().as_str(),
"https://cdn.example.com/signed/token-abc/file.bin",
"refresh must NEVER store the transient final_url in the .fd"
);
assert_eq!(
inner.etag.as_ref().unwrap().as_deref(),
Some("etag-2"),
"refresh must update the etag"
);
assert_eq!(
inner.last_modified.as_ref().unwrap().as_deref(),
Some("Mon, 02 Aug 2026 00:00:00 GMT"),
"refresh must update last-modified"
);
assert_eq!(inner.size, Some(2048), "refresh must update size");
}
}