Files
lanspread/crates/lanspread-peer/src/local_games.rs
T
ddidderr e8e7d7a93e feat: store launcher state outside game dirs
Move launcher-owned metadata from game roots into the configured peer state
area. Peer identity, the local library index, install intent logs, and setup
markers now live under app/CLI state instead of being written beside games.
The Tauri shell passes its app data directory into the peer, and the peer CLI
runs the same path through its explicit --state-dir.

Add a dedicated pre-start migration phase for legacy files. It migrates the
old global library index, per-game install intents, and the old first-start
marker into app state, then deletes legacy files only after the replacement
write succeeds. Normal scan, install, recovery, and transfer paths no longer
read legacy state files.

Rename the old first-start meaning to setup_done and only set it after
launching game_setup.cmd. Start/setup scripts keep the shared argument shape,
while server_start.cmd now uses cmd /k and a visible window so server logs stay
open for inspection.

While validating the Docker scenario matrix, make download terminal events
come from the handler after local state refresh and operation cleanup. This
makes download-finished/download-failed safe points for immediate follow-up CLI
commands. Also update the multi-peer chunking scenario to use a sparse archive
large enough to actually span multiple production chunks.

Test Plan:
- just fmt
- just test
- just frontend-test
- just build
- just clippy
- git diff --check
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py

Refs: local app-state migration discussion
2026-05-21 17:04:00 +02:00

925 lines
29 KiB
Rust

//! 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, 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<String, OperationKind>,
catalog: &HashSet<String>,
) -> 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
}
// =============================================================================
// 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<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
#[derive(Debug, Clone, Serialize, Deserialize)]
struct LibraryIndex {
revision: u64,
games: HashMap<String, GameIndexEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct GameIndexEntry {
summary: GameSummary,
fingerprint: GameFingerprint,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct GameFingerprint {
eti_files: Vec<EtiFingerprint>,
version_mtime: Option<u64>,
#[serde(default)]
version_contents: Option<String>,
local_dir_present: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct EtiFingerprint {
name: String,
size: u64,
mtime: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct LocalLibraryScan {
pub game_db: GameDB,
pub summaries: HashMap<String, GameSummary>,
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<Vec<EtiFingerprint>> {
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<GameFingerprint> {
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<Vec<GameFileDescription>, 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<GameSummary, PeerError> {
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<GameSummary>,
changed: bool,
}
async fn update_index_for_game(
game_root: &Path,
game_id: &str,
catalog: &HashSet<String>,
index: &mut LibraryIndex,
) -> eyre::Result<IndexUpdate> {
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::<HashMap<_, _>>();
let games = index
.games
.values()
.map(|entry| game_from_summary(&entry.summary))
.collect::<Vec<_>>();
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<Path>,
state_dir: impl AsRef<Path>,
catalog: &HashSet<String>,
) -> eyre::Result<LocalLibraryScan> {
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<Path>,
state_dir: impl AsRef<Path>,
catalog: &HashSet<String>,
game_id: &str,
) -> eyre::Result<LocalLibraryScan> {
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<Path>,
) -> Result<Vec<GameFileDescription>, PeerError> {
scan_game_descriptions(game_id, game_dir.as_ref()).await
}
#[cfg(test)]
mod tests {
use std::{
collections::{HashMap, HashSet},
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 = HashSet::from([
"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 = HashSet::from(["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 = HashSet::from(["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 = HashSet::from(["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, &HashSet::new()).await
);
assert!(!local_download_available(temp.path(), "missing", &no_operations, &catalog).await);
}
}