1use std::path::{Path, PathBuf};
8use std::time::SystemTime;
9
10use crate::config::FetchOptions;
11use crate::error::{Error, Result};
12use crate::fetch::fetcher::Fetcher;
13use crate::net::http::HttpClient;
14
15#[derive(Debug, Clone)]
17pub struct RemoteMetadata {
18 pub etag: Option<String>,
20 pub last_modified: Option<String>,
22 pub content_length: Option<u64>,
24}
25
26#[derive(Debug, Clone)]
28pub struct ConditionalOptions {
29 pub force: bool,
31 pub store_metadata: bool,
33}
34
35impl Default for ConditionalOptions {
36 fn default() -> Self {
37 Self {
38 force: false,
39 store_metadata: true,
40 }
41 }
42}
43
44pub struct ConditionalFetcher<C: HttpClient> {
46 base_fetcher: Fetcher<C>,
47 metadata_dir: PathBuf,
48}
49
50impl<C: HttpClient + 'static> ConditionalFetcher<C> {
51 pub fn new(client: C, workspace_root: impl Into<PathBuf>) -> Self {
53 let workspace_root = workspace_root.into();
54 Self {
55 base_fetcher: Fetcher::new(client, workspace_root.clone()),
56 metadata_dir: workspace_root.join(".metadata"),
57 }
58 }
59
60 pub async fn fetch_conditional(
62 &self,
63 url: &str,
64 destination: &Path,
65 options: FetchOptions,
66 conditional_options: ConditionalOptions,
67 ) -> Result<Option<PathBuf>> {
68 tokio::fs::create_dir_all(&self.metadata_dir)
70 .await
71 .map_err(|e| Error::Network(e.to_string()))?;
72
73 let remote_metadata = self.get_remote_metadata(url).await?;
75
76 if !conditional_options.force
78 && let Some(local_metadata) = self.load_local_metadata(url, destination).await?
79 && self.is_content_unchanged(&local_metadata, &remote_metadata)
80 {
81 return Ok(None); }
83
84 let result = self
86 .base_fetcher
87 .fetch_with_receipt(url, destination, options)
88 .await;
89
90 match result {
91 Ok(receipt) => {
92 if conditional_options.store_metadata {
94 let _ = self
95 .store_metadata(url, destination, &remote_metadata)
96 .await;
97 }
98 Ok(Some(receipt.destination))
99 }
100 Err(e) => Err(e),
101 }
102 }
103
104 async fn get_remote_metadata(&self, url: &str) -> Result<RemoteMetadata> {
106 let total_bytes = self
109 .base_fetcher
110 .head(url)
111 .await
112 .map_err(|e| Error::Network(e.to_string()))?;
113
114 Ok(RemoteMetadata {
115 etag: None, last_modified: None, content_length: total_bytes,
118 })
119 }
120
121 async fn load_local_metadata(
123 &self,
124 url: &str,
125 destination: &Path,
126 ) -> Result<Option<RemoteMetadata>> {
127 let metadata_path = self.metadata_path(url, destination);
128
129 if !metadata_path.exists() {
130 return Ok(None);
131 }
132
133 let content = tokio::fs::read_to_string(&metadata_path)
134 .await
135 .map_err(|e| Error::Network(e.to_string()))?;
136
137 Ok(Some(RemoteMetadata {
139 etag: None,
140 last_modified: None,
141 content_length: content.parse().ok(),
142 }))
143 }
144
145 async fn store_metadata(
147 &self,
148 url: &str,
149 destination: &Path,
150 metadata: &RemoteMetadata,
151 ) -> Result<()> {
152 let metadata_path = self.metadata_path(url, destination);
153
154 tokio::fs::create_dir_all(&self.metadata_dir)
156 .await
157 .map_err(|e| Error::Network(e.to_string()))?;
158
159 if let Some(content_length) = metadata.content_length {
161 tokio::fs::write(&metadata_path, content_length.to_string())
162 .await
163 .map_err(|e| Error::Network(e.to_string()))?;
164 }
165
166 Ok(())
167 }
168
169 fn is_content_unchanged(&self, local: &RemoteMetadata, remote: &RemoteMetadata) -> bool {
171 if let (Some(local_etag), Some(remote_etag)) = (&local.etag, &remote.etag) {
173 return local_etag == remote_etag;
174 }
175
176 if let (Some(local_modified), Some(remote_modified)) =
178 (&local.last_modified, &remote.last_modified)
179 {
180 return local_modified == remote_modified;
181 }
182
183 if let (Some(local_length), Some(remote_length)) =
185 (local.content_length, remote.content_length)
186 {
187 return local_length == remote_length;
188 }
189
190 false }
192
193 fn metadata_path(&self, url: &str, destination: &Path) -> PathBuf {
195 use std::collections::hash_map::DefaultHasher;
196 use std::hash::{Hash, Hasher};
197
198 let mut hasher = DefaultHasher::new();
200 url.hash(&mut hasher);
201 destination.hash(&mut hasher);
202 let hash = hasher.finish();
203
204 self.metadata_dir
205 .join(format!("metadata_{:016x}.txt", hash))
206 }
207
208 pub async fn cleanup_old_metadata(&self, max_age_seconds: u64) -> Result<usize> {
210 let mut cleaned = 0;
211 let _cutoff = SystemTime::now()
212 .duration_since(std::time::UNIX_EPOCH)
213 .unwrap_or_default()
214 .as_secs()
215 - max_age_seconds;
216
217 if !self.metadata_dir.exists() {
219 return Ok(0);
220 }
221
222 let mut entries = tokio::fs::read_dir(&self.metadata_dir)
223 .await
224 .map_err(|e| Error::Network(e.to_string()))?;
225
226 while let Some(entry) = entries
227 .next_entry()
228 .await
229 .map_err(|e| Error::Network(e.to_string()))?
230 {
231 let path = entry.path();
232
233 if path.extension().and_then(|s| s.to_str()) == Some("txt") {
234 if max_age_seconds == 0 {
235 let _ = tokio::fs::remove_file(&path).await;
236 cleaned += 1;
237 continue;
238 }
239
240 let metadata = entry
241 .metadata()
242 .await
243 .map_err(|e| Error::Network(e.to_string()))?;
244
245 if let Ok(modified) = metadata.modified()
246 && let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH)
247 {
248 let now = std::time::SystemTime::now()
253 .duration_since(std::time::UNIX_EPOCH)
254 .unwrap_or_default()
255 .as_secs();
256 if duration.as_secs() < (now - max_age_seconds) {
257 let _ = tokio::fs::remove_file(&path).await;
258 cleaned += 1;
259 }
260 }
261 }
262 }
263
264 Ok(cleaned)
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use std::time::Duration;
272 use tempfile::TempDir;
273 use tokio::time::sleep;
274
275 #[derive(Debug)]
277 struct MockClient;
278
279 impl MockClient {
280 fn new() -> Self {
281 Self
282 }
283 }
284
285 #[derive(Debug)]
286 struct MockError(String);
287
288 impl std::fmt::Display for MockError {
289 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
290 write!(f, "{}", self.0)
291 }
292 }
293
294 impl std::error::Error for MockError {}
295
296 impl HttpClient for MockClient {
297 type Error = MockError;
298
299 async fn stream(
300 &self,
301 _url: &str,
302 _headers: &[(String, String)],
303 ) -> std::result::Result<
304 crate::net::http::BoxStream<'static, std::result::Result<bytes::Bytes, Self::Error>>,
305 Self::Error,
306 > {
307 let empty: crate::net::http::BoxStream<
308 'static,
309 std::result::Result<bytes::Bytes, Self::Error>,
310 > = Box::pin(futures_util::stream::empty());
311 Ok(empty)
312 }
313
314 async fn head(&self, _url: &str) -> std::result::Result<Option<u64>, Self::Error> {
315 Ok(Some(1024))
316 }
317 }
318
319 #[test]
320 fn test_remote_metadata() {
321 let metadata = RemoteMetadata {
322 etag: Some("\"abc123\"".to_string()),
323 last_modified: Some("Wed, 21 Oct 2015 07:28:00 GMT".to_string()),
324 content_length: Some(1024),
325 };
326
327 assert_eq!(metadata.etag, Some("\"abc123\"".to_string()));
328 assert_eq!(
329 metadata.last_modified,
330 Some("Wed, 21 Oct 2015 07:28:00 GMT".to_string())
331 );
332 assert_eq!(metadata.content_length, Some(1024));
333 }
334
335 #[test]
336 fn test_conditional_options_default() {
337 let options = ConditionalOptions::default();
338 assert!(!options.force);
339 assert!(options.store_metadata);
340 }
341
342 #[test]
343 fn test_is_content_unchanged() {
344 let fetcher = ConditionalFetcher::<MockClient>::new(
345 MockClient::new(),
346 TempDir::new().unwrap().path(),
347 );
348
349 let local = RemoteMetadata {
351 etag: Some("\"abc123\"".to_string()),
352 last_modified: None,
353 content_length: None,
354 };
355 let remote_same = RemoteMetadata {
356 etag: Some("\"abc123\"".to_string()),
357 last_modified: None,
358 content_length: None,
359 };
360 let remote_different = RemoteMetadata {
361 etag: Some("\"def456\"".to_string()),
362 last_modified: None,
363 content_length: None,
364 };
365
366 assert!(fetcher.is_content_unchanged(&local, &remote_same));
367 assert!(!fetcher.is_content_unchanged(&local, &remote_different));
368
369 let local = RemoteMetadata {
371 etag: None,
372 last_modified: Some("Wed, 21 Oct 2015 07:28:00 GMT".to_string()),
373 content_length: None,
374 };
375 let remote_same = RemoteMetadata {
376 etag: None,
377 last_modified: Some("Wed, 21 Oct 2015 07:28:00 GMT".to_string()),
378 content_length: None,
379 };
380 let remote_different = RemoteMetadata {
381 etag: None,
382 last_modified: Some("Thu, 22 Oct 2015 07:28:00 GMT".to_string()),
383 content_length: None,
384 };
385
386 assert!(fetcher.is_content_unchanged(&local, &remote_same));
387 assert!(!fetcher.is_content_unchanged(&local, &remote_different));
388
389 let local = RemoteMetadata {
391 etag: None,
392 last_modified: None,
393 content_length: Some(1024),
394 };
395 let remote_same = RemoteMetadata {
396 etag: None,
397 last_modified: None,
398 content_length: Some(1024),
399 };
400 let remote_different = RemoteMetadata {
401 etag: None,
402 last_modified: None,
403 content_length: Some(2048),
404 };
405
406 assert!(fetcher.is_content_unchanged(&local, &remote_same));
407 assert!(!fetcher.is_content_unchanged(&local, &remote_different));
408 }
409
410 #[tokio::test]
411 async fn test_metadata_path() {
412 let temp_dir = TempDir::new().unwrap();
413 let fetcher = ConditionalFetcher::<MockClient>::new(MockClient::new(), temp_dir.path());
414
415 let url = "https://example.com/file.txt";
416 let destination = Path::new("/tmp/file.txt");
417
418 let path1 = fetcher.metadata_path(url, destination);
419 let path2 = fetcher.metadata_path(url, destination);
420 let path3 = fetcher.metadata_path("https://example.com/other.txt", destination);
421
422 assert_eq!(path1, path2);
424
425 assert_ne!(path1, path3);
427
428 assert!(path1.starts_with(temp_dir.path().join(".metadata")));
430 assert!(
431 path1
432 .file_name()
433 .unwrap()
434 .to_str()
435 .unwrap()
436 .starts_with("metadata_")
437 );
438 }
439
440 #[tokio::test]
441 async fn test_store_and_load_metadata() {
442 let temp_dir = TempDir::new().unwrap();
443 let fetcher: ConditionalFetcher<MockClient> =
444 ConditionalFetcher::new(MockClient::new(), temp_dir.path());
445
446 let url = "https://example.com/file.txt";
447 let destination = Path::new("/tmp/file.txt");
448 let metadata = RemoteMetadata {
449 etag: Some("\"abc123\"".to_string()),
450 last_modified: Some("Wed, 21 Oct 2015 07:28:00 GMT".to_string()),
451 content_length: Some(1024),
452 };
453
454 fetcher
456 .store_metadata(url, destination, &metadata)
457 .await
458 .unwrap();
459
460 let loaded = fetcher.load_local_metadata(url, destination).await.unwrap();
462 assert!(loaded.is_some());
463
464 assert_eq!(loaded.unwrap().content_length, Some(1024));
467 }
468
469 #[tokio::test]
470 async fn test_cleanup_old_metadata() {
471 let temp_dir = TempDir::new().unwrap();
472 let fetcher: ConditionalFetcher<MockClient> =
473 ConditionalFetcher::new(MockClient::new(), temp_dir.path());
474
475 let url = "https://example.com/file.txt";
476 let destination = Path::new("/tmp/file.txt");
477 let metadata = RemoteMetadata {
478 etag: None,
479 last_modified: None,
480 content_length: Some(1024),
481 };
482
483 fetcher
485 .store_metadata(url, destination, &metadata)
486 .await
487 .unwrap();
488
489 sleep(Duration::from_millis(10)).await;
491
492 let cleaned = fetcher.cleanup_old_metadata(0).await.unwrap();
494
495 assert_eq!(cleaned, 1);
496 }
497}