use std::path::{Path, PathBuf};
use std::time::SystemTime;
use crate::config::FetchOptions;
use crate::error::{Error, Result};
use crate::fetch::fetcher::Fetcher;
use crate::net::http::HttpClient;
#[derive(Debug, Clone)]
pub struct RemoteMetadata {
pub etag: Option<String>,
pub last_modified: Option<String>,
pub content_length: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct ConditionalOptions {
pub force: bool,
pub store_metadata: bool,
}
impl Default for ConditionalOptions {
fn default() -> Self {
Self {
force: false,
store_metadata: true,
}
}
}
pub struct ConditionalFetcher<C: HttpClient> {
base_fetcher: Fetcher<C>,
metadata_dir: PathBuf,
}
impl<C: HttpClient + 'static> ConditionalFetcher<C> {
pub fn new(client: C, workspace_root: impl Into<PathBuf>) -> Self {
let workspace_root = workspace_root.into();
Self {
base_fetcher: Fetcher::new(client, workspace_root.clone()),
metadata_dir: workspace_root.join(".metadata"),
}
}
pub async fn fetch_conditional(
&self,
url: &str,
destination: &Path,
options: FetchOptions,
conditional_options: ConditionalOptions,
) -> Result<Option<PathBuf>> {
tokio::fs::create_dir_all(&self.metadata_dir)
.await
.map_err(|e| Error::Network(e.to_string()))?;
let remote_metadata = self.get_remote_metadata(url).await?;
if !conditional_options.force
&& let Some(local_metadata) = self.load_local_metadata(url, destination).await?
&& self.is_content_unchanged(&local_metadata, &remote_metadata)
{
return Ok(None); }
let result = self
.base_fetcher
.fetch_with_receipt(url, destination, options)
.await;
match result {
Ok(receipt) => {
if conditional_options.store_metadata {
let _ = self
.store_metadata(url, destination, &remote_metadata)
.await;
}
Ok(Some(receipt.destination))
}
Err(e) => Err(e),
}
}
async fn get_remote_metadata(&self, url: &str) -> Result<RemoteMetadata> {
let total_bytes = self
.base_fetcher
.head(url)
.await
.map_err(|e| Error::Network(e.to_string()))?;
Ok(RemoteMetadata {
etag: None, last_modified: None, content_length: total_bytes,
})
}
async fn load_local_metadata(
&self,
url: &str,
destination: &Path,
) -> Result<Option<RemoteMetadata>> {
let metadata_path = self.metadata_path(url, destination);
if !metadata_path.exists() {
return Ok(None);
}
let content = tokio::fs::read_to_string(&metadata_path)
.await
.map_err(|e| Error::Network(e.to_string()))?;
Ok(Some(RemoteMetadata {
etag: None,
last_modified: None,
content_length: content.parse().ok(),
}))
}
async fn store_metadata(
&self,
url: &str,
destination: &Path,
metadata: &RemoteMetadata,
) -> Result<()> {
let metadata_path = self.metadata_path(url, destination);
tokio::fs::create_dir_all(&self.metadata_dir)
.await
.map_err(|e| Error::Network(e.to_string()))?;
if let Some(content_length) = metadata.content_length {
tokio::fs::write(&metadata_path, content_length.to_string())
.await
.map_err(|e| Error::Network(e.to_string()))?;
}
Ok(())
}
fn is_content_unchanged(&self, local: &RemoteMetadata, remote: &RemoteMetadata) -> bool {
if let (Some(local_etag), Some(remote_etag)) = (&local.etag, &remote.etag) {
return local_etag == remote_etag;
}
if let (Some(local_modified), Some(remote_modified)) =
(&local.last_modified, &remote.last_modified)
{
return local_modified == remote_modified;
}
if let (Some(local_length), Some(remote_length)) =
(local.content_length, remote.content_length)
{
return local_length == remote_length;
}
false }
fn metadata_path(&self, url: &str, destination: &Path) -> PathBuf {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
url.hash(&mut hasher);
destination.hash(&mut hasher);
let hash = hasher.finish();
self.metadata_dir
.join(format!("metadata_{:016x}.txt", hash))
}
pub async fn cleanup_old_metadata(&self, max_age_seconds: u64) -> Result<usize> {
let mut cleaned = 0;
let _cutoff = SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
- max_age_seconds;
if !self.metadata_dir.exists() {
return Ok(0);
}
let mut entries = tokio::fs::read_dir(&self.metadata_dir)
.await
.map_err(|e| Error::Network(e.to_string()))?;
while let Some(entry) = entries
.next_entry()
.await
.map_err(|e| Error::Network(e.to_string()))?
{
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("txt") {
if max_age_seconds == 0 {
let _ = tokio::fs::remove_file(&path).await;
cleaned += 1;
continue;
}
let metadata = entry
.metadata()
.await
.map_err(|e| Error::Network(e.to_string()))?;
if let Ok(modified) = metadata.modified()
&& let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH)
{
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if duration.as_secs() < (now - max_age_seconds) {
let _ = tokio::fs::remove_file(&path).await;
cleaned += 1;
}
}
}
}
Ok(cleaned)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use tempfile::TempDir;
use tokio::time::sleep;
#[derive(Debug)]
struct MockClient;
impl MockClient {
fn new() -> Self {
Self
}
}
#[derive(Debug)]
struct MockError(String);
impl std::fmt::Display for MockError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for MockError {}
impl HttpClient for MockClient {
type Error = MockError;
async fn stream(
&self,
_url: &str,
_headers: &[(String, String)],
) -> std::result::Result<
crate::net::http::BoxStream<'static, std::result::Result<bytes::Bytes, Self::Error>>,
Self::Error,
> {
let empty: crate::net::http::BoxStream<
'static,
std::result::Result<bytes::Bytes, Self::Error>,
> = Box::pin(futures_util::stream::empty());
Ok(empty)
}
async fn head(&self, _url: &str) -> std::result::Result<Option<u64>, Self::Error> {
Ok(Some(1024))
}
}
#[test]
fn test_remote_metadata() {
let metadata = RemoteMetadata {
etag: Some("\"abc123\"".to_string()),
last_modified: Some("Wed, 21 Oct 2015 07:28:00 GMT".to_string()),
content_length: Some(1024),
};
assert_eq!(metadata.etag, Some("\"abc123\"".to_string()));
assert_eq!(
metadata.last_modified,
Some("Wed, 21 Oct 2015 07:28:00 GMT".to_string())
);
assert_eq!(metadata.content_length, Some(1024));
}
#[test]
fn test_conditional_options_default() {
let options = ConditionalOptions::default();
assert!(!options.force);
assert!(options.store_metadata);
}
#[test]
fn test_is_content_unchanged() {
let fetcher = ConditionalFetcher::<MockClient>::new(
MockClient::new(),
TempDir::new().unwrap().path(),
);
let local = RemoteMetadata {
etag: Some("\"abc123\"".to_string()),
last_modified: None,
content_length: None,
};
let remote_same = RemoteMetadata {
etag: Some("\"abc123\"".to_string()),
last_modified: None,
content_length: None,
};
let remote_different = RemoteMetadata {
etag: Some("\"def456\"".to_string()),
last_modified: None,
content_length: None,
};
assert!(fetcher.is_content_unchanged(&local, &remote_same));
assert!(!fetcher.is_content_unchanged(&local, &remote_different));
let local = RemoteMetadata {
etag: None,
last_modified: Some("Wed, 21 Oct 2015 07:28:00 GMT".to_string()),
content_length: None,
};
let remote_same = RemoteMetadata {
etag: None,
last_modified: Some("Wed, 21 Oct 2015 07:28:00 GMT".to_string()),
content_length: None,
};
let remote_different = RemoteMetadata {
etag: None,
last_modified: Some("Thu, 22 Oct 2015 07:28:00 GMT".to_string()),
content_length: None,
};
assert!(fetcher.is_content_unchanged(&local, &remote_same));
assert!(!fetcher.is_content_unchanged(&local, &remote_different));
let local = RemoteMetadata {
etag: None,
last_modified: None,
content_length: Some(1024),
};
let remote_same = RemoteMetadata {
etag: None,
last_modified: None,
content_length: Some(1024),
};
let remote_different = RemoteMetadata {
etag: None,
last_modified: None,
content_length: Some(2048),
};
assert!(fetcher.is_content_unchanged(&local, &remote_same));
assert!(!fetcher.is_content_unchanged(&local, &remote_different));
}
#[tokio::test]
async fn test_metadata_path() {
let temp_dir = TempDir::new().unwrap();
let fetcher = ConditionalFetcher::<MockClient>::new(MockClient::new(), temp_dir.path());
let url = "https://example.com/file.txt";
let destination = Path::new("/tmp/file.txt");
let path1 = fetcher.metadata_path(url, destination);
let path2 = fetcher.metadata_path(url, destination);
let path3 = fetcher.metadata_path("https://example.com/other.txt", destination);
assert_eq!(path1, path2);
assert_ne!(path1, path3);
assert!(path1.starts_with(temp_dir.path().join(".metadata")));
assert!(
path1
.file_name()
.unwrap()
.to_str()
.unwrap()
.starts_with("metadata_")
);
}
#[tokio::test]
async fn test_store_and_load_metadata() {
let temp_dir = TempDir::new().unwrap();
let fetcher: ConditionalFetcher<MockClient> =
ConditionalFetcher::new(MockClient::new(), temp_dir.path());
let url = "https://example.com/file.txt";
let destination = Path::new("/tmp/file.txt");
let metadata = RemoteMetadata {
etag: Some("\"abc123\"".to_string()),
last_modified: Some("Wed, 21 Oct 2015 07:28:00 GMT".to_string()),
content_length: Some(1024),
};
fetcher
.store_metadata(url, destination, &metadata)
.await
.unwrap();
let loaded = fetcher.load_local_metadata(url, destination).await.unwrap();
assert!(loaded.is_some());
assert_eq!(loaded.unwrap().content_length, Some(1024));
}
#[tokio::test]
async fn test_cleanup_old_metadata() {
let temp_dir = TempDir::new().unwrap();
let fetcher: ConditionalFetcher<MockClient> =
ConditionalFetcher::new(MockClient::new(), temp_dir.path());
let url = "https://example.com/file.txt";
let destination = Path::new("/tmp/file.txt");
let metadata = RemoteMetadata {
etag: None,
last_modified: None,
content_length: Some(1024),
};
fetcher
.store_metadata(url, destination, &metadata)
.await
.unwrap();
sleep(Duration::from_millis(10)).await;
let cleaned = fetcher.cleanup_old_metadata(0).await.unwrap();
assert_eq!(cleaned, 1);
}
}