diff --git a/crates/empack-lib/src/application/commands.rs b/crates/empack-lib/src/application/commands.rs index d305db2..eced3e7 100644 --- a/crates/empack-lib/src/application/commands.rs +++ b/crates/empack-lib/src/application/commands.rs @@ -3601,9 +3601,28 @@ async fn continue_pending_restricted_build_inner( let results = run_build_pipeline(session, &build_targets, archive_format, true).await?; let restricted_entries = collect_restricted_entries(&results); if !restricted_entries.is_empty() { + let cache_dir = pending.restricted_cache_path(); + display_restricted_mod_infos(session, &cache_dir, &restricted_entries)?; + let rerun_comparison = compare_rerun_restricted_entries(&pending, &restricted_entries); + let (diagnostic, cache_label) = restricted_rerun_diagnostic(rerun_comparison); + session.display().status().warning(diagnostic); + if let Some(cache_label) = cache_label { + session + .display() + .status() + .info(&format!("{cache_label}: {}", cache_dir.display())); + } + let restricted_count = count_unique_restricted_mod_urls(&restricted_entries); + if let Some(detail) = restricted_rerun_error_detail(&restricted_entries) { + return Err(anyhow::anyhow!( + "{} restricted download(s) are still required after continue: {}", + restricted_count, + detail + )); + } return Err(anyhow::anyhow!( "{} restricted download(s) are still required after continue", - count_unique_restricted_mod_urls(&restricted_entries) + restricted_count )); } @@ -3748,10 +3767,17 @@ fn count_unique_restricted_mod_urls( let mut seen = std::collections::HashSet::new(); entries .iter() - .filter(|entry| seen.insert(entry.url.clone())) + .filter(|entry| seen.insert(restricted_entry_url_key(&entry.url))) .count() } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RestrictedRerunComparison { + Same, + Subset, + Different, +} + fn restricted_download_dirs( downloads_dir: Option<&str>, pending: &crate::empack::restricted_build::PendingRestrictedBuild, @@ -3790,6 +3816,150 @@ fn dedup_restricted_entry_urls( .collect() } +fn dedup_restricted_mod_infos( + entries: &[crate::empack::packwiz::RestrictedModInfo], +) -> Vec<&crate::empack::packwiz::RestrictedModInfo> { + let mut seen = std::collections::HashSet::new(); + entries + .iter() + .filter(|entry| { + seen.insert(( + restricted_entry_url_key(&entry.url), + entry.dest_path.clone(), + )) + }) + .collect() +} + +fn dedup_restricted_mod_urls( + entries: &[crate::empack::packwiz::RestrictedModInfo], +) -> Vec<&crate::empack::packwiz::RestrictedModInfo> { + let mut seen = std::collections::HashSet::new(); + entries + .iter() + .filter(|entry| seen.insert(restricted_entry_url_key(&entry.url))) + .collect() +} + +fn restricted_entry_url_key(url: &str) -> String { + crate::empack::packwiz::restricted_curseforge_file_id(url) + .map(|file_id| format!("curseforge:{file_id}")) + .unwrap_or_else(|| url.to_string()) +} + +fn compare_rerun_restricted_entries( + pending: &crate::empack::restricted_build::PendingRestrictedBuild, + rerun_entries: &[crate::empack::packwiz::RestrictedModInfo], +) -> RestrictedRerunComparison { + let pending_signatures: HashSet<(String, String, String)> = pending + .entries + .iter() + .map(|entry| { + ( + restricted_entry_url_key(&entry.url), + entry.dest_path.clone(), + entry.filename.clone(), + ) + }) + .collect(); + let rerun_signatures: HashSet<(String, String, String)> = rerun_entries + .iter() + .map(|entry| { + ( + restricted_entry_url_key(&entry.url), + entry.dest_path.clone(), + crate::empack::packwiz::restricted_destination_filename(&entry.dest_path) + .unwrap_or_default(), + ) + }) + .collect(); + + if rerun_signatures == pending_signatures { + RestrictedRerunComparison::Same + } else if rerun_signatures.is_subset(&pending_signatures) { + RestrictedRerunComparison::Subset + } else { + RestrictedRerunComparison::Different + } +} + +fn restricted_rerun_diagnostic( + comparison: RestrictedRerunComparison, +) -> (&'static str, Option<&'static str>) { + match comparison { + RestrictedRerunComparison::Same => ( + "The same restricted download(s) were reported again after restore. The managed cache entry may be stale, invalid, or the wrong file.", + Some("Managed cache"), + ), + RestrictedRerunComparison::Subset => ( + "A subset of the original restricted download(s) were reported again after restore. The remaining managed cache entries may be stale, invalid, or the wrong file.", + Some("Managed cache"), + ), + RestrictedRerunComparison::Different => ( + "The rerun reported a different restricted download set than the original pending state.", + None, + ), + } +} + +fn restricted_mod_info_summary(entry: &crate::empack::packwiz::RestrictedModInfo) -> String { + format!("{} [{} -> {}]", entry.name, entry.url, entry.dest_path) +} + +fn restricted_rerun_error_detail( + rerun_entries: &[crate::empack::packwiz::RestrictedModInfo], +) -> Option { + let summaries = dedup_restricted_mod_infos(rerun_entries) + .into_iter() + .map(restricted_mod_info_summary) + .collect::>() + .join("; "); + + if summaries.is_empty() { + None + } else { + Some(summaries) + } +} + +fn display_restricted_mod_infos( + session: &dyn Session, + cache_dir: &Path, + restricted_entries: &[crate::empack::packwiz::RestrictedModInfo], +) -> Result<()> { + let unique_entries = dedup_restricted_mod_urls(restricted_entries); + + session.display().status().section(&format!( + "Build incomplete: {} restricted download(s) are still required after continue", + unique_entries.len() + )); + + for entry in unique_entries { + session + .display() + .status() + .warning(&format!(" {}", entry.name)); + session + .display() + .status() + .info(&format!(" Download: {}", entry.url)); + if let Some(filename) = + crate::empack::packwiz::restricted_destination_filename(&entry.dest_path) + { + session.display().status().info(&format!( + " Cache as: {}", + cache_dir.join(filename).display() + )); + } + session + .display() + .status() + .subtle(&format!(" Will restore to: {}", entry.dest_path)); + } + + Ok(()) +} + fn display_pending_restricted_build( session: &dyn Session, pending: &crate::empack::restricted_build::PendingRestrictedBuild, diff --git a/crates/empack-lib/src/application/commands.test.rs b/crates/empack-lib/src/application/commands.test.rs index 48c9f86..6d81e8b 100644 --- a/crates/empack-lib/src/application/commands.test.rs +++ b/crates/empack-lib/src/application/commands.test.rs @@ -4366,6 +4366,24 @@ mod handle_build_continue_tests { } } + fn different_restricted_install_output( + workdir: &std::path::Path, + ) -> crate::application::session::ProcessOutput { + crate::application::session::ProcessOutput { + stdout: "Failed to download modpack, the following errors were encountered:\nReplayMod.jar:".to_string(), + stderr: format!( + "java.lang.Exception: This mod is excluded from the CurseForge API and must be downloaded manually.\nPlease go to https://www.curseforge.com/minecraft/mc-mods/replay-mod/files/222 and save this file to {}\n\tat link.infra.packwiz.installer.DownloadTask.download(DownloadTask.java:42)", + workdir + .join("dist") + .join("client-full") + .join("mods") + .join("ReplayMod.jar") + .to_string_lossy() + ), + success: false, + } + } + fn mrpack_export_args(workdir: &Path) -> Vec { vec![ "--pack-file".to_string(), @@ -5047,6 +5065,98 @@ mod handle_build_continue_tests { ); } + #[tokio::test] + async fn build_continue_refreshes_preexisting_stale_cache_from_exact_download() { + let _guard = crate::test_support::env_lock().lock_async().await; + let cache_root = TempDir::new().expect("cache root tempdir"); + let _cache_dir = unsafe { EnvVarGuard::set("EMPACK_CACHE_DIR", cache_root.path()) }; + + let workdir = mock_root().join("continue-refresh-stale-cache-exact"); + let downloads_dir = workdir.join("manual-downloads"); + let session = MockCommandSession::new() + .with_filesystem(cached_full_build_filesystem(workdir.clone())) + .with_process(MockProcessProvider::new().with_java_installer_side_effects()); + + let mut pending = crate::empack::restricted_build::save_pending_build( + session.filesystem(), + &workdir, + &[BuildTarget::ClientFull], + crate::empack::archive::ArchiveFormat::Zip, + &[crate::empack::RestrictedModInfo { + name: "OptiFine.jar".to_string(), + url: "https://www.curseforge.com/minecraft/mc-mods/optifine/files/4912891" + .to_string(), + dest_path: workdir + .join("dist") + .join("client-full") + .join("mods") + .join("OptiFine.jar") + .to_string_lossy() + .to_string(), + }], + ) + .expect("save pending build"); + session + .filesystem() + .create_dir_all(&workdir.join("dist").join("client-full")) + .expect("create client-full output"); + + let cache_path = pending.restricted_cache_path().join("OptiFine.jar"); + let stale_meta = recent_file_metadata("stale bytes".len()); + session + .filesystem() + .write_bytes(&cache_path, b"stale bytes") + .expect("write stale cache"); + session + .filesystem_provider + .set_file_metadata(cache_path.clone(), stale_meta.clone()); + pending.candidate_baseline = vec![ + crate::empack::restricted_build::PendingRestrictedCandidateSnapshot { + path: cache_path.to_string_lossy().to_string(), + len: stale_meta.len, + modified_unix_ms: stale_meta.modified_unix_ms, + created_unix_ms: stale_meta.created_unix_ms, + }, + ]; + crate::empack::restricted_build::persist_pending_build( + session.filesystem(), + &workdir, + &pending, + ) + .expect("persist pending build with stale cache baseline"); + + let exact_download = downloads_dir.join("OptiFine.jar"); + session + .filesystem() + .write_bytes(&exact_download, b"fresh manual bytes") + .expect("write fresh manual download"); + session + .filesystem_provider + .set_file_metadata(exact_download, recent_file_metadata("fresh manual bytes".len())); + + let result = handle_build( + &session, + &BuildArgs { + continue_build: true, + downloads_dir: Some(downloads_dir.to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await; + + assert!( + result.is_ok(), + "continue build should refresh a stale managed cache entry from an exact manual download: {result:?}" + ); + assert_eq!( + session + .filesystem() + .read_bytes(&pending.restricted_cache_path().join("OptiFine.jar")) + .expect("read refreshed cache"), + b"fresh manual bytes" + ); + } + #[tokio::test] async fn fresh_restricted_build_non_tty_does_not_prompt_or_wait() { let _guard = crate::test_support::env_lock().lock_async().await; @@ -5747,7 +5857,8 @@ mod handle_build_continue_tests { } #[tokio::test] - async fn build_continue_errors_when_restricted_downloads_recur() { + async fn build_continue_reports_specific_restricted_entries_when_rerun_still_requires_manual_download( + ) { let _guard = crate::test_support::env_lock().lock_async().await; let cache_root = TempDir::new().expect("cache root tempdir"); let _cache_dir = unsafe { EnvVarGuard::set("EMPACK_CACHE_DIR", cache_root.path()) }; @@ -5801,9 +5912,175 @@ mod handle_build_continue_tests { .await .expect_err("repeated restricted installer output should fail"); - assert!(err - .to_string() - .contains("restricted download(s) are still required after continue")); + let err_text = err.to_string(); + assert!(err_text.contains("restricted download(s) are still required after continue")); + assert!(err_text.contains("OptiFine.jar")); + assert!(err_text.contains("https://www.curseforge.com/minecraft/mc-mods/optifine/download/4912891")); + assert!(!err_text.contains( + "The same restricted download(s) were reported again after restore. The managed cache entry may be stale, invalid, or the wrong file." + )); + let optifine_dest = workdir + .join("dist") + .join("client-full") + .join("mods") + .join("OptiFine.jar") + .to_string_lossy() + .to_string(); + assert!(err_text.contains(&optifine_dest)); + } + + #[tokio::test] + async fn build_continue_reports_same_entry_repeated_after_continue_as_stale_cache_hint() { + let _guard = crate::test_support::env_lock().lock_async().await; + let cache_root = TempDir::new().expect("cache root tempdir"); + let _cache_dir = unsafe { EnvVarGuard::set("EMPACK_CACHE_DIR", cache_root.path()) }; + + let workdir = mock_root().join("continue-restricted-again-stale-cache-hint"); + let session = MockCommandSession::new() + .with_filesystem(cached_full_build_filesystem(workdir.clone())) + .with_process(MockProcessProvider::new().with_result( + "java".to_string(), + client_full_installer_args(&workdir), + Ok(restricted_install_output(&workdir)), + )); + + let pending = crate::empack::restricted_build::save_pending_build( + session.filesystem(), + &workdir, + &[BuildTarget::ClientFull], + crate::empack::archive::ArchiveFormat::Zip, + &[crate::empack::RestrictedModInfo { + name: "OptiFine.jar".to_string(), + url: "https://www.curseforge.com/minecraft/mc-mods/optifine/files/4912891" + .to_string(), + dest_path: workdir + .join("dist") + .join("client-full") + .join("mods") + .join("OptiFine.jar") + .to_string_lossy() + .to_string(), + }], + ) + .expect("save pending build"); + session + .filesystem() + .create_dir_all(&workdir.join("dist").join("client-full")) + .expect("create client-full output"); + session + .filesystem() + .write_bytes(&pending.restricted_cache_path().join("OptiFine.jar"), b"cached bytes") + .expect("write cached restricted file"); + + let err = continue_pending_restricted_build( + &session, + &workdir, + &BuildArgs { + continue_build: true, + ..Default::default() + }, + std::time::Instant::now(), + ) + .await + .expect_err("repeated restricted installer output should fail"); + + let err_text = err.to_string(); + assert!(err_text.contains("restricted download(s) are still required after continue")); + assert!(err_text.contains("OptiFine.jar")); + assert!(err_text.contains( + "https://www.curseforge.com/minecraft/mc-mods/optifine/download/4912891" + )); + assert!(!err_text.contains( + "The same restricted download(s) were reported again after restore. The managed cache entry may be stale, invalid, or the wrong file." + )); + } + + #[tokio::test] + async fn build_continue_reports_different_rerun_restricted_set_when_entries_change() { + let _guard = crate::test_support::env_lock().lock_async().await; + let cache_root = TempDir::new().expect("cache root tempdir"); + let _cache_dir = unsafe { EnvVarGuard::set("EMPACK_CACHE_DIR", cache_root.path()) }; + + let workdir = mock_root().join("continue-restricted-again-different-set"); + let session = MockCommandSession::new() + .with_filesystem(cached_full_build_filesystem(workdir.clone())) + .with_process(MockProcessProvider::new().with_result( + "java".to_string(), + client_full_installer_args(&workdir), + Ok(different_restricted_install_output(&workdir)), + )); + + let pending = crate::empack::restricted_build::save_pending_build( + session.filesystem(), + &workdir, + &[BuildTarget::ClientFull], + crate::empack::archive::ArchiveFormat::Zip, + &[crate::empack::RestrictedModInfo { + name: "OptiFine.jar".to_string(), + url: "https://www.curseforge.com/minecraft/mc-mods/optifine/files/4912891" + .to_string(), + dest_path: workdir + .join("dist") + .join("client-full") + .join("mods") + .join("OptiFine.jar") + .to_string_lossy() + .to_string(), + }], + ) + .expect("save pending build"); + session + .filesystem() + .create_dir_all(&workdir.join("dist").join("client-full")) + .expect("create client-full output"); + session + .filesystem() + .write_bytes(&pending.restricted_cache_path().join("OptiFine.jar"), b"cached bytes") + .expect("write cached restricted file"); + + let err = continue_pending_restricted_build( + &session, + &workdir, + &BuildArgs { + continue_build: true, + ..Default::default() + }, + std::time::Instant::now(), + ) + .await + .expect_err("different rerun restricted output should fail"); + + let err_text = err.to_string(); + assert!(err_text.contains("restricted download(s) are still required after continue")); + assert!(!err_text.contains( + "The rerun reported a different restricted download set than the original pending state." + )); + assert!(err_text.contains( + "ReplayMod.jar" + )); + assert!(err_text.contains( + "https://www.curseforge.com/minecraft/mc-mods/replay-mod/download/222" + )); + } + + #[test] + fn dedup_restricted_mod_infos_normalizes_curseforge_url_variants() { + let entries = vec![ + crate::empack::RestrictedModInfo { + name: "OptiFine.jar".to_string(), + url: "https://www.curseforge.com/minecraft/mc-mods/optifine/files/4912891" + .to_string(), + dest_path: "/tmp/dist/client-full/mods/OptiFine.jar".to_string(), + }, + crate::empack::RestrictedModInfo { + name: "OptiFine.jar".to_string(), + url: "https://www.curseforge.com/minecraft/mc-mods/optifine/download/4912891" + .to_string(), + dest_path: "/tmp/dist/client-full/mods/OptiFine.jar".to_string(), + }, + ]; + + assert_eq!(dedup_restricted_mod_infos(&entries).len(), 1); } #[tokio::test] diff --git a/crates/empack-lib/src/empack/restricted_build.rs b/crates/empack-lib/src/empack/restricted_build.rs index 53c411c..6a5438e 100644 --- a/crates/empack-lib/src/empack/restricted_build.rs +++ b/crates/empack-lib/src/empack/restricted_build.rs @@ -56,6 +56,13 @@ pub struct PendingRestrictedBuild { pub entries: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CacheEntryStatus { + Missing, + Current, + PreexistingUnchanged, +} + impl PendingRestrictedBuild { pub fn target_list(&self) -> Result> { self.targets @@ -235,15 +242,32 @@ pub fn import_matching_downloads_into_cache( for (filename, entry) in entries_by_filename { let cache_path = cache_dir.join(&filename); - if provider.exists(&cache_path) { + let mut cache_status = + cache_entry_status(provider, &cache_path, &pending.candidate_baseline); + tracing::debug!( + filename = %filename, + cache_path = %cache_path.display(), + cache_status = ?cache_status, + "restricted download evaluated cache entry status" + ); + if matches!(cache_status, CacheEntryStatus::Current) { continue; } if let Some(candidate) = find_exact_candidate(provider, &cache_path, &filename, &search_dirs) { + tracing::debug!( + filename = %filename, + candidate = %candidate.display(), + refreshes_stale_cache = matches!(cache_status, CacheEntryStatus::PreexistingUnchanged), + "restricted download exact filename candidate selected" + ); import_candidate_into_cache(provider, &candidate, &cache_path)?; - continue; + cache_status = cache_entry_status(provider, &cache_path, &pending.candidate_baseline); + if matches!(cache_status, CacheEntryStatus::Current) { + continue; + } } tracing::debug!( @@ -267,9 +291,22 @@ pub fn import_matching_downloads_into_cache( &pending.candidate_baseline, &used_fallback_hashes, )? { + tracing::debug!( + filename = %filename, + candidate = %candidate.display(), + refreshes_stale_cache = matches!(cache_status, CacheEntryStatus::PreexistingUnchanged), + "restricted download baseline-aware fallback selected a refresh candidate" + ); import_candidate_into_cache(provider, &candidate, &cache_path)?; used_fallback_hashes.insert(hash); + continue; } + tracing::debug!( + filename = %filename, + cache_path = %cache_path.display(), + cache_status = ?cache_status, + "restricted download remains unresolved after baseline-aware candidate scan" + ); continue; } @@ -295,9 +332,23 @@ pub fn import_matching_downloads_into_cache( recent_cutoff_ms, &used_fallback_hashes, )? { + tracing::debug!( + filename = %filename, + candidate = %candidate.display(), + refreshes_stale_cache = matches!(cache_status, CacheEntryStatus::PreexistingUnchanged), + "restricted download legacy recent-file fallback selected a refresh candidate" + ); import_candidate_into_cache(provider, &candidate, &cache_path)?; used_fallback_hashes.insert(hash); + continue; } + + tracing::debug!( + filename = %filename, + cache_path = %cache_path.display(), + cache_status = ?cache_status, + "restricted download remains unresolved after legacy recent-file candidate scan" + ); } Ok(()) @@ -311,7 +362,16 @@ pub fn missing_cached_entries( pending .entries .iter() - .filter(|entry| !provider.exists(&cache_dir.join(&entry.filename))) + .filter(|entry| { + !matches!( + cache_entry_status( + provider, + &cache_dir.join(&entry.filename), + &pending.candidate_baseline + ), + CacheEntryStatus::Current + ) + }) .cloned() .collect() } @@ -325,7 +385,10 @@ pub fn stage_cached_entries_to_destinations( for entry in &pending.entries { let cache_path = cache_dir.join(&entry.filename); - if !provider.exists(&cache_path) { + if !matches!( + cache_entry_status(provider, &cache_path, &pending.candidate_baseline), + CacheEntryStatus::Current + ) { missing.push(entry.clone()); continue; } @@ -378,6 +441,38 @@ fn find_exact_candidate( None } +fn cache_entry_status( + provider: &dyn FileSystemProvider, + cache_path: &Path, + baseline: &[PendingRestrictedCandidateSnapshot], +) -> CacheEntryStatus { + if !provider.exists(cache_path) { + return CacheEntryStatus::Missing; + } + + if baseline.is_empty() { + return CacheEntryStatus::Current; + } + + let metadata = match provider.file_metadata(cache_path) { + Ok(metadata) => metadata, + Err(error) => { + tracing::debug!( + path = %cache_path.display(), + error = %error, + "treating restricted cache entry with unreadable metadata as missing" + ); + return CacheEntryStatus::Missing; + } + }; + + if snapshot_changed(cache_path, &metadata, baseline) { + CacheEntryStatus::Current + } else { + CacheEntryStatus::PreexistingUnchanged + } +} + pub fn capture_candidate_baseline( provider: &dyn FileSystemProvider, search_dirs: &[PathBuf], diff --git a/crates/empack-lib/src/empack/restricted_build.test.rs b/crates/empack-lib/src/empack/restricted_build.test.rs index dc950c2..6e4e19d 100644 --- a/crates/empack-lib/src/empack/restricted_build.test.rs +++ b/crates/empack-lib/src/empack/restricted_build.test.rs @@ -742,6 +742,242 @@ fn import_matching_downloads_into_cache_ignores_preexisting_recent_zip_noise_whe ); } +#[test] +fn missing_cached_entries_treats_preexisting_unchanged_cache_as_missing_when_baseline_exists() { + let _guard = crate::test_support::env_lock().lock().unwrap(); + let cache_root = TempDir::new().expect("cache root tempdir"); + let _cache_dir = unsafe { EnvVarGuard::set("EMPACK_CACHE_DIR", cache_root.path()) }; + + let workdir = mock_root().join("restricted-build-stale-cache-missing"); + let provider = MockFileSystemProvider::new() + .with_current_dir(workdir.clone()) + .with_configured_project(workdir.clone()); + + let mut pending = save_pending_build( + &provider, + &workdir, + &[BuildTarget::ClientFull], + ArchiveFormat::Zip, + &[sample_resourcepack_restricted_mod(&workdir)], + ) + .expect("save pending build"); + let cache_path = pending.restricted_cache_path().join("No_Enchant_Glint.zip"); + let cache_meta = recent_file_metadata("stale cache bytes".len(), 200_000); + provider + .write_bytes(&cache_path, b"stale cache bytes") + .expect("write stale cache bytes"); + provider.set_file_metadata(cache_path.clone(), cache_meta.clone()); + pending.candidate_baseline = vec![baseline_snapshot(cache_path, &cache_meta)]; + + assert_eq!(missing_cached_entries(&provider, &pending).len(), 1); +} + +#[test] +fn stage_cached_entries_to_destinations_treats_preexisting_unchanged_cache_as_missing_when_baseline_exists( +) { + let _guard = crate::test_support::env_lock().lock().unwrap(); + let cache_root = TempDir::new().expect("cache root tempdir"); + let _cache_dir = unsafe { EnvVarGuard::set("EMPACK_CACHE_DIR", cache_root.path()) }; + + let workdir = mock_root().join("restricted-build-stale-cache-stage-missing"); + let provider = MockFileSystemProvider::new() + .with_current_dir(workdir.clone()) + .with_configured_project(workdir.clone()); + + let mut pending = save_pending_build( + &provider, + &workdir, + &[BuildTarget::ClientFull], + ArchiveFormat::Zip, + &[sample_resourcepack_restricted_mod(&workdir)], + ) + .expect("save pending build"); + let cache_path = pending.restricted_cache_path().join("No_Enchant_Glint.zip"); + let cache_meta = recent_file_metadata("stale cache bytes".len(), 200_000); + provider + .write_bytes(&cache_path, b"stale cache bytes") + .expect("write stale cache bytes"); + provider.set_file_metadata(cache_path.clone(), cache_meta.clone()); + pending.candidate_baseline = vec![baseline_snapshot(cache_path, &cache_meta)]; + + let missing = stage_cached_entries_to_destinations(&provider, &pending) + .expect("stage stale cache entry"); + assert_eq!(missing.len(), 1); + assert!( + !provider.exists(&PathBuf::from(&pending.entries[0].dest_path)), + "stale cache entry should not be restored to the destination" + ); +} + +#[test] +fn import_matching_downloads_into_cache_refreshes_preexisting_stale_cache_from_exact_candidate() { + let _guard = crate::test_support::env_lock().lock().unwrap(); + let cache_root = TempDir::new().expect("cache root tempdir"); + let _cache_dir = unsafe { EnvVarGuard::set("EMPACK_CACHE_DIR", cache_root.path()) }; + + let workdir = mock_root().join("restricted-build-stale-cache-exact-refresh"); + let downloads_dir = workdir.join("downloads"); + let provider = MockFileSystemProvider::new() + .with_current_dir(workdir.clone()) + .with_configured_project(workdir.clone()); + + let mut pending = save_pending_build( + &provider, + &workdir, + &[BuildTarget::ClientFull], + ArchiveFormat::Zip, + &[sample_resourcepack_restricted_mod(&workdir)], + ) + .expect("save pending build"); + let cache_path = pending.restricted_cache_path().join("No_Enchant_Glint.zip"); + let cache_meta = recent_file_metadata("stale cache bytes".len(), 200_000); + provider + .write_bytes(&cache_path, b"stale cache bytes") + .expect("write stale cache bytes"); + provider.set_file_metadata(cache_path.clone(), cache_meta.clone()); + pending.candidate_baseline = vec![baseline_snapshot(cache_path.clone(), &cache_meta)]; + + provider + .write_bytes(&downloads_dir.join("No_Enchant_Glint.zip"), b"fresh exact bytes") + .expect("write fresh exact download"); + provider.set_file_metadata( + downloads_dir.join("No_Enchant_Glint.zip"), + recent_file_metadata("fresh exact bytes".len(), 205_000), + ); + + import_matching_downloads_into_cache( + &provider, + &workdir, + &pending, + std::slice::from_ref(&downloads_dir), + ) + .expect("refresh stale cache from exact candidate"); + + assert_eq!( + provider.read_bytes(&cache_path).expect("read refreshed cache"), + b"fresh exact bytes" + ); + assert!(missing_cached_entries(&provider, &pending).is_empty()); +} + +#[test] +fn import_matching_downloads_into_cache_refreshes_preexisting_stale_cache_from_unique_baseline_candidate( +) { + let _guard = crate::test_support::env_lock().lock().unwrap(); + let cache_root = TempDir::new().expect("cache root tempdir"); + let _cache_dir = unsafe { EnvVarGuard::set("EMPACK_CACHE_DIR", cache_root.path()) }; + + let workdir = mock_root().join("restricted-build-stale-cache-fallback-refresh"); + let downloads_dir = workdir.join("downloads"); + let provider = MockFileSystemProvider::new() + .with_current_dir(workdir.clone()) + .with_configured_project(workdir.clone()); + + let mut pending = save_pending_build( + &provider, + &workdir, + &[BuildTarget::ClientFull], + ArchiveFormat::Zip, + &[sample_resourcepack_restricted_mod(&workdir)], + ) + .expect("save pending build"); + let cache_path = pending.restricted_cache_path().join("No_Enchant_Glint.zip"); + let cache_meta = recent_file_metadata("stale cache bytes".len(), 200_000); + provider + .write_bytes(&cache_path, b"stale cache bytes") + .expect("write stale cache bytes"); + provider.set_file_metadata(cache_path.clone(), cache_meta.clone()); + pending.candidate_baseline = vec![baseline_snapshot(cache_path.clone(), &cache_meta)]; + + let variant_path = downloads_dir.join("ยง6No Enchant Glint 1.20.1.zip"); + provider + .write_bytes(&variant_path, b"fresh fallback bytes") + .expect("write fallback candidate"); + provider.set_file_metadata( + variant_path, + recent_file_metadata("fresh fallback bytes".len(), 205_000), + ); + + import_matching_downloads_into_cache( + &provider, + &workdir, + &pending, + std::slice::from_ref(&downloads_dir), + ) + .expect("refresh stale cache from unique fallback candidate"); + + assert_eq!( + provider.read_bytes(&cache_path).expect("read refreshed cache"), + b"fresh fallback bytes" + ); + assert!(missing_cached_entries(&provider, &pending).is_empty()); +} + +#[test] +fn import_matching_downloads_into_cache_leaves_preexisting_stale_cache_unresolved_without_new_candidate( +) { + let _guard = crate::test_support::env_lock().lock().unwrap(); + let cache_root = TempDir::new().expect("cache root tempdir"); + let _cache_dir = unsafe { EnvVarGuard::set("EMPACK_CACHE_DIR", cache_root.path()) }; + + let workdir = mock_root().join("restricted-build-stale-cache-unresolved"); + let provider = MockFileSystemProvider::new() + .with_current_dir(workdir.clone()) + .with_configured_project(workdir.clone()); + + let mut pending = save_pending_build( + &provider, + &workdir, + &[BuildTarget::ClientFull], + ArchiveFormat::Zip, + &[sample_resourcepack_restricted_mod(&workdir)], + ) + .expect("save pending build"); + let cache_path = pending.restricted_cache_path().join("No_Enchant_Glint.zip"); + let cache_meta = recent_file_metadata("stale cache bytes".len(), 200_000); + provider + .write_bytes(&cache_path, b"stale cache bytes") + .expect("write stale cache bytes"); + provider.set_file_metadata(cache_path.clone(), cache_meta.clone()); + pending.candidate_baseline = vec![baseline_snapshot(cache_path.clone(), &cache_meta)]; + + import_matching_downloads_into_cache(&provider, &workdir, &pending, &[]) + .expect("scan without new candidates"); + + assert_eq!( + provider.read_bytes(&cache_path).expect("read unchanged cache"), + b"stale cache bytes" + ); + assert_eq!(missing_cached_entries(&provider, &pending).len(), 1); +} + +#[test] +fn missing_cached_entries_legacy_pending_still_trusts_existing_cache() { + let _guard = crate::test_support::env_lock().lock().unwrap(); + let cache_root = TempDir::new().expect("cache root tempdir"); + let _cache_dir = unsafe { EnvVarGuard::set("EMPACK_CACHE_DIR", cache_root.path()) }; + + let workdir = mock_root().join("restricted-build-legacy-cache-trust"); + let provider = MockFileSystemProvider::new() + .with_current_dir(workdir.clone()) + .with_configured_project(workdir.clone()); + + let pending = save_pending_build( + &provider, + &workdir, + &[BuildTarget::ClientFull], + ArchiveFormat::Zip, + &[sample_resourcepack_restricted_mod(&workdir)], + ) + .expect("save pending build"); + let cache_path = pending.restricted_cache_path().join("No_Enchant_Glint.zip"); + provider + .write_bytes(&cache_path, b"legacy cache bytes") + .expect("write legacy cache bytes"); + + assert!(missing_cached_entries(&provider, &pending).is_empty()); +} + #[test] fn import_matching_downloads_into_cache_keeps_blocking_when_multiple_new_distinct_zip_candidates_exist_after_baseline( ) { diff --git a/crates/empack-tests/tests/build_continue.rs b/crates/empack-tests/tests/build_continue.rs index 6635c67..b714c02 100644 --- a/crates/empack-tests/tests/build_continue.rs +++ b/crates/empack-tests/tests/build_continue.rs @@ -209,3 +209,119 @@ async fn e2e_build_continue_imports_recent_unicode_variant_download() -> Result< Ok(()) } + +#[tokio::test] +async fn e2e_build_continue_refreshes_stale_restricted_cache_from_new_manual_download() -> Result<()> +{ + let project_name = "continue-stale-cache-refresh"; + let workdir = empack_lib::application::session_mocks::mock_root().join("workdir"); + let downloads_dir = workdir.join("manual-downloads"); + let cache_filename = "No_Enchant_Glint.zip"; + + let session = MockSessionBuilder::new() + .with_empack_project(project_name, "1.21.1", "fabric") + .build(); + + let mut pending = empack_lib::empack::restricted_build::save_pending_build( + session.filesystem(), + &workdir, + &[empack_lib::primitives::BuildTarget::Mrpack], + ArchiveFormat::Zip, + &[RestrictedModInfo { + name: "No Enchant Glint".to_string(), + url: "https://www.curseforge.com/minecraft/texture-packs/no-enchant-glint/download/4660358" + .to_string(), + dest_path: workdir + .join("packwiz-cache") + .join("import") + .join(cache_filename) + .to_string_lossy() + .to_string(), + }], + )?; + + let cache_path = pending.restricted_cache_path().join(cache_filename); + session + .filesystem() + .write_bytes(&cache_path, b"stale cache bytes")?; + let stale_now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let stale_meta = FileMetadata { + is_directory: false, + len: "stale cache bytes".len() as u64, + modified_unix_ms: Some(stale_now), + created_unix_ms: Some(stale_now), + }; + session + .filesystem_provider + .set_file_metadata(cache_path.clone(), stale_meta.clone()); + pending.candidate_baseline = vec![ + empack_lib::empack::restricted_build::PendingRestrictedCandidateSnapshot { + path: cache_path.to_string_lossy().to_string(), + len: stale_meta.len, + modified_unix_ms: stale_meta.modified_unix_ms, + created_unix_ms: stale_meta.created_unix_ms, + }, + ]; + empack_lib::empack::restricted_build::persist_pending_build( + session.filesystem(), + &workdir, + &pending, + )?; + + session.filesystem_provider.write_bytes( + &downloads_dir.join(cache_filename), + b"fresh manual resource pack bytes", + )?; + let fresh_now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + session.filesystem_provider.set_file_metadata( + downloads_dir.join(cache_filename), + FileMetadata { + is_directory: false, + len: "fresh manual resource pack bytes".len() as u64, + modified_unix_ms: Some(fresh_now), + created_unix_ms: Some(fresh_now), + }, + ); + + Display::init_or_get(TerminalCapabilities::minimal()); + + let result = execute_command_with_session( + Commands::Build(BuildArgs { + continue_build: true, + downloads_dir: Some(downloads_dir.to_string_lossy().to_string()), + ..Default::default() + }), + &session, + ) + .await; + + assert!( + result.is_ok(), + "continue build should refresh a stale cache entry from a new exact manual download: {result:?}" + ); + assert_eq!( + session.filesystem().read_bytes(&cache_path)?, + b"fresh manual resource pack bytes" + ); + assert!( + session.filesystem().exists( + &workdir + .join("dist") + .join(format!("{project_name}-v1.0.0.mrpack")) + ), + "continued build should produce the mrpack archive" + ); + assert!( + empack_lib::empack::restricted_build::load_pending_build(session.filesystem(), &workdir)? + .is_none(), + "pending restricted state should be cleared after continue succeeds" + ); + + Ok(()) +}