//! Local game scanning and database management. use std::{ collections::{HashMap, HashSet}, hash::{Hash, Hasher}, io::ErrorKind, path::{Path, PathBuf}, sync::LazyLock, time::{SystemTime, UNIX_EPOCH}, }; use lanspread_db::db::{Game, GameCatalog, GameDB, GameFileDescription}; use lanspread_proto::{Availability, GameSummary}; use serde::{Deserialize, Serialize}; use tokio::{io::AsyncWriteExt, sync::Mutex}; use crate::{context::OperationKind, error::PeerError}; // ============================================================================= // Local directory helpers // ============================================================================= #[cfg(target_os = "windows")] pub fn is_local_dir_name(name: &str) -> bool { name.eq_ignore_ascii_case("local") } #[cfg(not(target_os = "windows"))] pub fn is_local_dir_name(name: &str) -> bool { name == "local" } /// Checks if `local/` is a committed install directory. pub async fn local_dir_is_directory(path: &Path) -> bool { let local_dir = path.join("local"); tokio::fs::metadata(&local_dir) .await .is_ok_and(|metadata| metadata.is_dir()) } /// Checks if the root-level `version.ini` sentinel exists as a regular file. pub async fn version_ini_is_regular_file(game_path: &Path) -> bool { let version_path = game_path.join("version.ini"); tokio::fs::metadata(&version_path) .await .is_ok_and(|metadata| metadata.is_file()) } /// Checks if a game is available for download locally. pub async fn local_download_available( game_dir: &Path, game_id: &str, active_operations: &HashMap, catalog: &GameCatalog, ) -> bool { if !catalog.contains(game_id) { log::debug!("Not serving game {game_id} locally because it is not in the catalog"); return false; } if active_operations.contains_key(game_id) { log::debug!("Not serving game {game_id} locally because an operation is active"); return false; } let game_path = game_dir.join(game_id); version_ini_is_regular_file(game_path.as_path()).await } /// Checks if a local game may be served to peers under the authoritative catalog version. pub async fn local_download_matches_catalog( game_dir: &Path, game_id: &str, active_operations: &HashMap, catalog: &GameCatalog, ) -> bool { if !local_download_available(game_dir, game_id, active_operations, catalog).await { return false; } let Some(expected_version) = catalog.expected_version(game_id) else { return true; }; let game_path = game_dir.join(game_id); match lanspread_db::db::read_version_from_ini(&game_path) { Ok(Some(local_version)) if local_version == expected_version => true, Ok(Some(local_version)) => { log::debug!( "Not serving game {game_id}: local version.ini {local_version} does not match catalog {expected_version}" ); false } Ok(None) => false, Err(err) => { log::warn!( "Not serving game {game_id}: failed to read local version.ini for catalog comparison: {err}" ); false } } } // ============================================================================= // Local library index and scanning // ============================================================================= const LEGACY_LIBRARY_INDEX_DIR: &str = ".lanspread"; const LIBRARY_INDEX_FILE: &str = "library_index.json"; const INTENT_LOG_FILE: &str = ".lanspread.json"; const VERSION_TMP_FILE: &str = ".version.ini.tmp"; const VERSION_DISCARDED_FILE: &str = ".version.ini.discarded"; static LIBRARY_INDEX_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); #[derive(Debug, Clone, Serialize, Deserialize)] struct LibraryIndex { revision: u64, games: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize)] struct GameIndexEntry { summary: GameSummary, fingerprint: GameFingerprint, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] struct GameFingerprint { eti_files: Vec, version_mtime: Option, #[serde(default)] version_contents: Option, local_dir_present: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] struct EtiFingerprint { name: String, size: u64, mtime: Option, } #[derive(Debug, Clone)] pub struct LocalLibraryScan { pub game_db: GameDB, pub summaries: HashMap, pub revision: u64, } pub(crate) fn legacy_library_index_path(game_dir: &Path) -> PathBuf { game_dir .join(LEGACY_LIBRARY_INDEX_DIR) .join(LIBRARY_INDEX_FILE) } fn library_index_path(state_dir: &Path) -> PathBuf { crate::state_paths::local_library_index_path(state_dir) } fn library_index_tmp_path(path: &Path) -> PathBuf { let Some(file_name) = path.file_name() else { return path.with_extension("tmp"); }; let mut tmp_name = file_name.to_os_string(); tmp_name.push(".tmp"); path.with_file_name(tmp_name) } async fn sweep_stale_library_index_tmp(path: &Path) { let tmp_path = library_index_tmp_path(path); match tokio::fs::remove_file(&tmp_path).await { Ok(()) => log::debug!( "Removed stale library index temp file {}", tmp_path.display() ), Err(err) if err.kind() == ErrorKind::NotFound => {} Err(err) => log::warn!( "Failed to remove stale library index temp file {}: {err}", tmp_path.display() ), } } async fn load_library_index(path: &Path) -> LibraryIndex { sweep_stale_library_index_tmp(path).await; let data = match tokio::fs::read_to_string(path).await { Ok(data) => data, Err(err) => { if err.kind() != ErrorKind::NotFound { log::warn!("Failed to read library index {}: {err}", path.display()); } return LibraryIndex { revision: 0, games: HashMap::new(), }; } }; match serde_json::from_str(&data) { Ok(index) => index, Err(err) => { log::warn!("Failed to parse library index {}: {err}", path.display()); LibraryIndex { revision: 0, games: HashMap::new(), } } } } async fn save_library_index(path: &Path, index: &LibraryIndex) -> eyre::Result<()> { if let Some(parent) = path.parent() { tokio::fs::create_dir_all(parent).await?; } let data = serde_json::to_vec_pretty(index)?; let tmp_path = library_index_tmp_path(path); let mut file = tokio::fs::File::create(&tmp_path).await?; file.write_all(&data).await?; file.sync_all().await?; drop(file); tokio::fs::rename(&tmp_path, path).await?; sync_parent_dir(path)?; Ok(()) } #[cfg(unix)] fn sync_parent_dir(path: &Path) -> std::io::Result<()> { if let Some(parent) = path.parent() { std::fs::File::open(parent)?.sync_all()?; } Ok(()) } #[cfg(not(unix))] fn sync_parent_dir(_path: &Path) -> std::io::Result<()> { Ok(()) } fn system_time_to_secs(time: SystemTime) -> u64 { time.duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() } fn is_root_eti_name(name: &str) -> bool { Path::new(name) .extension() .is_some_and(|extension| extension == "eti") } async fn root_eti_fingerprints(game_path: &Path) -> eyre::Result> { let mut entries = match tokio::fs::read_dir(game_path).await { Ok(entries) => entries, Err(err) if err.kind() == ErrorKind::NotFound => return Ok(Vec::new()), Err(err) => return Err(err.into()), }; let mut eti_files = Vec::new(); while let Some(entry) = entries.next_entry().await? { let file_type = entry.file_type().await?; if !file_type.is_file() { continue; } let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else { continue; }; if !is_root_eti_name(&name) { continue; } let metadata = entry.metadata().await?; eti_files.push(EtiFingerprint { name, size: metadata.len(), mtime: metadata.modified().ok().map(system_time_to_secs), }); } eti_files.sort_by(|a, b| a.name.cmp(&b.name)); Ok(eti_files) } async fn fingerprint_game_dir(game_path: &Path) -> eyre::Result { let eti_files = root_eti_fingerprints(game_path).await?; let version_path = game_path.join("version.ini"); let (version_mtime, version_contents) = match tokio::fs::metadata(&version_path).await { Ok(metadata) if metadata.is_file() => { let contents = match tokio::fs::read_to_string(&version_path).await { Ok(contents) => Some(contents.trim().to_string()), Err(err) => { log::warn!( "Failed to read {} for fingerprinting: {err}", version_path.display() ); None } }; (metadata.modified().ok().map(system_time_to_secs), contents) } Err(_) | Ok(_) => (None, None), }; let local_dir_present = local_dir_is_directory(game_path).await; Ok(GameFingerprint { eti_files, version_mtime, version_contents, local_dir_present, }) } pub fn is_ignored_game_root_name(name: &str) -> bool { name == LEGACY_LIBRARY_INDEX_DIR } fn is_reserved_transient_name(name: &str) -> bool { name.starts_with(".local.") || name == VERSION_TMP_FILE || name == VERSION_DISCARDED_FILE || name == INTENT_LOG_FILE || name == LEGACY_LIBRARY_INDEX_DIR } fn should_skip_root_entry(entry: &walkdir::DirEntry) -> bool { if entry.depth() != 1 { return false; } if entry.file_type().is_dir() && entry.file_name().to_str().is_some_and(is_local_dir_name) { return true; } if let Some(name) = entry.file_name().to_str() { if is_reserved_transient_name(name) { return true; } if entry.file_type().is_dir() && name == ".sync" { return true; } if entry.file_type().is_file() && name == ".softlan_game_installed" { return true; } } false } async fn scan_game_descriptions( game_id: &str, game_dir: &Path, ) -> Result, PeerError> { let base_dir = game_dir; let game_path = base_dir.join(game_id); if !game_path.exists() { return Err(PeerError::Other(eyre::eyre!( "Game directory does not exist: {}", game_path.display() ))); } let mut file_descriptions = Vec::new(); for entry in walkdir::WalkDir::new(&game_path) .into_iter() .filter_entry(|entry| !should_skip_root_entry(entry)) .filter_map(std::result::Result::ok) { let relative_path = match entry.path().strip_prefix(base_dir) { Ok(path) => path.to_string_lossy().to_string(), Err(e) => { log::error!( "Failed to get relative path for {}: {}", entry.path().display(), e ); continue; } }; let is_dir = entry.file_type().is_dir(); let size = if is_dir { 0 } else { match tokio::fs::metadata(entry.path()).await { Ok(metadata) => metadata.len(), Err(e) => { log::error!("Failed to read metadata for {relative_path}: {e}"); return Err(PeerError::FileSizeDetermination { path: relative_path.clone(), source: e, }); } } }; let file_desc = GameFileDescription { game_id: game_id.to_string(), relative_path, is_dir, size, }; file_descriptions.push(file_desc); } Ok(file_descriptions) } fn manifest_hash(file_descriptions: &[GameFileDescription]) -> u64 { let mut entries: Vec<_> = file_descriptions .iter() .filter(|desc| !desc.is_dir) .map(|desc| (&desc.relative_path, desc.size, desc.is_dir)) .collect(); entries.sort_by(|a, b| a.0.cmp(b.0).then(a.1.cmp(&b.1))); let mut hasher = std::collections::hash_map::DefaultHasher::new(); for (path, size, is_dir) in entries { path.hash(&mut hasher); size.hash(&mut hasher); is_dir.hash(&mut hasher); } hasher.finish() } async fn build_game_summary(game_dir: &Path, game_id: &str) -> Result { let game_path = game_dir.join(game_id); let downloaded = version_ini_is_regular_file(&game_path).await; let installed = local_dir_is_directory(&game_path).await; let eti_version = if downloaded { match lanspread_db::db::read_version_from_ini(&game_path) { Ok(version) => version, Err(e) => { log::warn!("Failed to read version.ini for downloaded game {game_id}: {e}"); None } } } else { None }; let file_descriptions = scan_game_descriptions(game_id, game_dir).await?; let total_size = file_descriptions .iter() .filter(|desc| !desc.is_dir) .map(|desc| desc.size) .sum(); let manifest_hash = manifest_hash(&file_descriptions); let availability = if downloaded { Availability::Ready } else { Availability::LocalOnly }; Ok(GameSummary { id: game_id.to_string(), name: game_id.to_string(), size: total_size, downloaded, installed, eti_version, manifest_hash, availability, }) } pub(crate) fn game_from_summary(summary: &GameSummary) -> Game { Game { id: summary.id.clone(), name: summary.name.clone(), description: String::new(), release_year: String::new(), publisher: String::new(), max_players: 1, version: "1.0".to_string(), genre: String::new(), size: summary.size, downloaded: summary.downloaded, installed: summary.installed, availability: summary.availability.clone(), eti_game_version: summary.eti_version.clone(), local_version: summary.eti_version.clone(), peer_count: 0, } } struct IndexUpdate { summary: Option, changed: bool, } async fn update_index_for_game( game_root: &Path, game_id: &str, catalog: &GameCatalog, index: &mut LibraryIndex, ) -> eyre::Result { if !catalog.contains(game_id) { return Ok(IndexUpdate { summary: None, changed: index.games.remove(game_id).is_some(), }); } let game_path = game_root.join(game_id); let fingerprint = fingerprint_game_dir(&game_path).await?; if fingerprint.version_mtime.is_none() && !fingerprint.local_dir_present && fingerprint.eti_files.is_empty() { return Ok(IndexUpdate { summary: None, changed: index.games.remove(game_id).is_some(), }); } let mut changed = false; let summary = match index.games.get(game_id) { Some(entry) if entry.fingerprint == fingerprint => entry.summary.clone(), _ => { changed = true; build_game_summary(game_root, game_id).await? } }; if index .games .get(game_id) .is_some_and(|entry| entry.summary.manifest_hash != summary.manifest_hash) { changed = true; } index.games.insert( game_id.to_string(), GameIndexEntry { summary: summary.clone(), fingerprint, }, ); Ok(IndexUpdate { summary: Some(summary), changed, }) } fn empty_scan() -> LocalLibraryScan { LocalLibraryScan { game_db: GameDB::empty(), summaries: HashMap::new(), revision: 0, } } fn scan_from_index(index: &LibraryIndex) -> LocalLibraryScan { let summaries = index .games .iter() .map(|(id, entry)| (id.clone(), entry.summary.clone())) .collect::>(); let games = index .games .values() .map(|entry| game_from_summary(&entry.summary)) .collect::>(); LocalLibraryScan { game_db: GameDB::from(games), summaries, revision: index.revision, } } // ============================================================================= // Game database loading // ============================================================================= /// Scans the local game directory and returns summaries plus a game database. pub async fn scan_local_library( game_dir: impl AsRef, state_dir: impl AsRef, catalog: &GameCatalog, ) -> eyre::Result { let game_path = game_dir.as_ref(); let state_path = state_dir.as_ref(); let metadata = match tokio::fs::metadata(game_path).await { Ok(metadata) => metadata, Err(err) => { if err.kind() == ErrorKind::NotFound { log::warn!( "Local game directory {} missing; reporting empty game database", game_path.display() ); return Ok(empty_scan()); } return Err(err.into()); } }; if !metadata.is_dir() { log::warn!( "Configured game directory {} is not a directory; reporting empty game database", game_path.display() ); return Ok(empty_scan()); } let _index_guard = LIBRARY_INDEX_LOCK.lock().await; let index_path = library_index_path(state_path); let mut index = load_library_index(&index_path).await; let mut seen_ids = HashSet::new(); let mut summaries = HashMap::new(); let mut games = Vec::new(); let mut changed = false; let mut entries = tokio::fs::read_dir(game_path).await?; while let Some(entry) = entries.next_entry().await? { let path = entry.path(); if !path.is_dir() { continue; } let Some(game_id) = path.file_name().and_then(|n| n.to_str()) else { continue; }; if is_ignored_game_root_name(game_id) { continue; } let update = update_index_for_game(game_path, game_id, catalog, &mut index).await?; changed |= update.changed; let Some(summary) = update.summary else { continue; }; seen_ids.insert(game_id.to_string()); summaries.insert(game_id.to_string(), summary.clone()); games.push(game_from_summary(&summary)); } let before = index.games.len(); index.games.retain(|game_id, _| seen_ids.contains(game_id)); if index.games.len() != before { changed = true; } if changed { index.revision = index.revision.saturating_add(1); if let Err(err) = save_library_index(&index_path, &index).await { log::warn!( "Failed to persist library index {}: {err}", index_path.display() ); } } Ok(LocalLibraryScan { game_db: GameDB::from(games), summaries, revision: index.revision, }) } /// Rescans a single game root through the cached index and returns full library state. pub async fn rescan_local_game( game_dir: impl AsRef, state_dir: impl AsRef, catalog: &GameCatalog, game_id: &str, ) -> eyre::Result { let game_path = game_dir.as_ref(); let state_path = state_dir.as_ref(); let _index_guard = LIBRARY_INDEX_LOCK.lock().await; let index_path = library_index_path(state_path); let mut index = load_library_index(&index_path).await; let update = update_index_for_game(game_path, game_id, catalog, &mut index).await?; if update.changed { index.revision = index.revision.saturating_add(1); if let Err(err) = save_library_index(&index_path, &index).await { log::warn!( "Failed to persist library index {}: {err}", index_path.display() ); } } Ok(scan_from_index(&index)) } // ============================================================================= // Game file descriptions // ============================================================================= /// Gets file descriptions for a game from the local filesystem. pub async fn get_game_file_descriptions( game_id: &str, game_dir: impl AsRef, ) -> Result, PeerError> { scan_game_descriptions(game_id, game_dir.as_ref()).await } #[cfg(test)] mod tests { use std::{collections::HashMap, path::Path}; use lanspread_proto::Availability; use super::*; use crate::{context::OperationKind, test_support::TempDir}; fn write_file(path: &Path, bytes: &[u8]) { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).expect("parent dir should be created"); } std::fs::write(path, bytes).expect("file should be written"); } fn test_library_index(revision: u64, id: &str, manifest_hash: u64) -> LibraryIndex { LibraryIndex { revision, games: HashMap::from([( id.to_string(), GameIndexEntry { summary: GameSummary { id: id.to_string(), name: id.to_string(), size: manifest_hash, downloaded: true, installed: false, eti_version: Some("20250101".to_string()), manifest_hash, availability: Availability::Ready, }, fingerprint: GameFingerprint { eti_files: Vec::new(), version_mtime: Some(manifest_hash), version_contents: Some("20250101".to_string()), local_dir_present: false, }, }, )]), } } #[tokio::test] async fn save_library_index_is_atomic_on_replace() { let temp = TempDir::new("lanspread-local-games"); let index_path = library_index_path(temp.path()); let tmp_path = library_index_tmp_path(&index_path); let first = test_library_index(1, "game-a", 11); save_library_index(&index_path, &first) .await .expect("first index write should succeed"); assert!(!tmp_path.exists()); let second = test_library_index(2, "game-b", 22); save_library_index(&index_path, &second) .await .expect("replacement index write should succeed"); assert!(!tmp_path.exists()); let loaded = load_library_index(&index_path).await; assert_eq!(loaded.revision, 2); assert!(!loaded.games.contains_key("game-a")); let game_b = loaded .games .get("game-b") .expect("replacement index should be persisted"); assert_eq!(game_b.summary.manifest_hash, 22); } #[tokio::test] async fn load_library_index_sweeps_stale_tmp() { let temp = TempDir::new("lanspread-local-games"); let index_path = library_index_path(temp.path()); let tmp_path = library_index_tmp_path(&index_path); let canonical = test_library_index(7, "game", 77); let data = serde_json::to_vec_pretty(&canonical).expect("index should serialize"); write_file(&index_path, &data); write_file(&tmp_path, b"{ not json"); let loaded = load_library_index(&index_path).await; assert_eq!(loaded.revision, 7); assert!(loaded.games.contains_key("game")); assert!(!tmp_path.exists()); } #[tokio::test] async fn scan_uses_version_ini_and_local_dir_as_independent_state() { let temp = TempDir::new("lanspread-local-games"); let state = TempDir::new("lanspread-local-games-state"); let catalog = GameCatalog::from_ids([ "ready".to_string(), "local-only".to_string(), "eti-only".to_string(), ]); write_file(&temp.path().join("ready").join("version.ini"), b"20250101"); std::fs::create_dir_all(temp.path().join("local-only").join("local")) .expect("local dir should be created"); write_file( &temp.path().join("eti-only").join("eti-only.eti"), b"archive", ); write_file( &temp.path().join("non-catalog").join("version.ini"), b"20250101", ); let scan = scan_local_library(temp.path(), state.path(), &catalog) .await .expect("scan should succeed"); let ready = scan .summaries .get("ready") .expect("catalog game with sentinel should be indexed"); assert!(ready.downloaded); assert!(!ready.installed); assert_eq!(ready.eti_version.as_deref(), Some("20250101")); assert_eq!(ready.availability, Availability::Ready); let local_only = scan .summaries .get("local-only") .expect("local-only install should be indexed"); assert!(!local_only.downloaded); assert!(local_only.installed); assert_eq!(local_only.availability, Availability::LocalOnly); let eti_only = scan .summaries .get("eti-only") .expect("eti-only root should be retained as local state"); assert!(!eti_only.downloaded); assert!(!eti_only.installed); assert_eq!(eti_only.availability, Availability::LocalOnly); assert!(!scan.summaries.contains_key("non-catalog")); } #[tokio::test] async fn rescan_promotes_installed_only_game_to_ready_when_sentinel_appears() { let temp = TempDir::new("lanspread-local-games"); let state = TempDir::new("lanspread-local-games-state"); let catalog = GameCatalog::from_ids(["game".to_string()]); std::fs::create_dir_all(temp.path().join("game").join("local")) .expect("local install dir should be created"); let first_scan = scan_local_library(temp.path(), state.path(), &catalog) .await .expect("initial scan should succeed"); let local_only = first_scan .summaries .get("game") .expect("installed-only game should be indexed"); assert!(!local_only.downloaded); assert!(local_only.installed); assert_eq!(local_only.availability, Availability::LocalOnly); write_file(&temp.path().join("game").join("version.ini"), b"20250101"); let rescan = rescan_local_game(temp.path(), state.path(), &catalog, "game") .await .expect("rescan should succeed"); let ready = rescan .summaries .get("game") .expect("ready game should remain indexed"); assert!(ready.downloaded); assert!(ready.installed); assert_eq!(ready.eti_version.as_deref(), Some("20250101")); assert_eq!(ready.availability, Availability::Ready); } #[tokio::test] async fn concurrent_rescans_preserve_both_index_updates() { let temp = TempDir::new("lanspread-local-games-concurrent"); let state = TempDir::new("lanspread-local-games-state"); let catalog = GameCatalog::from_ids(["game-a".to_string(), "game-b".to_string()]); write_file(&temp.path().join("game-a").join("version.ini"), b"20250101"); write_file(&temp.path().join("game-b").join("version.ini"), b"20250101"); let initial = scan_local_library(temp.path(), state.path(), &catalog) .await .expect("initial scan should succeed"); assert_eq!(initial.revision, 1); write_file(&temp.path().join("game-a").join("game-a.eti"), b"archive-a"); write_file(&temp.path().join("game-b").join("game-b.eti"), b"archive-b"); let (scan_a, scan_b) = tokio::join!( rescan_local_game(temp.path(), state.path(), &catalog, "game-a"), rescan_local_game(temp.path(), state.path(), &catalog, "game-b") ); scan_a.expect("game-a rescan should succeed"); scan_b.expect("game-b rescan should succeed"); let index = load_library_index(&library_index_path(state.path())).await; assert_eq!(index.revision, 3); let game_a = index .games .get("game-a") .expect("game-a update should remain in index"); let game_b = index .games .get("game-b") .expect("game-b update should remain in index"); assert!( game_a.summary.size > 8, "game-a rescan should persist the new archive" ); assert!( game_b.summary.size > 8, "game-b rescan should persist the new archive" ); } #[tokio::test] async fn local_download_available_gates_on_catalog_operation_and_sentinel() { let temp = TempDir::new("lanspread-local-games"); let game_root = temp.path().join("game"); write_file(&game_root.join("version.ini"), b"20250101"); let catalog = GameCatalog::from_ids(["game".to_string()]); let no_operations = HashMap::new(); assert!(local_download_available(temp.path(), "game", &no_operations, &catalog).await); let active_operations = HashMap::from([("game".to_string(), OperationKind::Downloading)]); assert!(!local_download_available(temp.path(), "game", &active_operations, &catalog).await); assert!( !local_download_available(temp.path(), "game", &no_operations, &GameCatalog::empty()) .await ); assert!(!local_download_available(temp.path(), "missing", &no_operations, &catalog).await); } #[tokio::test] async fn local_download_matches_catalog_requires_expected_version() { let temp = TempDir::new("lanspread-local-games"); let game_root = temp.path().join("game"); write_file(&game_root.join("version.ini"), b"20260101"); let mut catalog = GameCatalog::empty(); catalog.insert("game".to_string(), Some("20250101".to_string())); let no_operations = HashMap::new(); assert!( !local_download_matches_catalog(temp.path(), "game", &no_operations, &catalog).await ); catalog.insert("game".to_string(), Some("20260101".to_string())); assert!( local_download_matches_catalog(temp.path(), "game", &no_operations, &catalog).await ); } }