refactor(peer): centralize game root path policy

Game scanning, manifest validation, install recovery, migration, and download
cleanup each carried their own spellings and case rules for reserved entries.
Those copies had already diverged, which made it possible for one subsystem to
accept or expose a path that another treated as application-owned state.

Introduce one game_paths module for the canonical names and conservative
portable comparison policy. Keep context-specific predicates for manifest and
scanner protection versus cancellation preservation: cancellation still sweeps
its own version transaction scratch files, while install and migration state
survive. Reuse the constants for all production path construction sites.

Test Plan:
- `just test` -- passed (175 lanspread-peer tests and full workspace)
- `just clippy` -- passed
- `just fmt` -- Rust, TOML, and Prettier completed; the recipe remains blocked
  by 39 pre-existing rumdl issues in five unrelated Markdown files
- `git diff --cached --check` -- passed
This commit is contained in:
ddidderr committed 2026-08-09 17:59:01 +02:00
1 parent a6ed60a538
commit a1013b028d
13 files changed
+198 -129

No files matched your search

+19 -48
View File
@@ -14,25 +14,25 @@ use lanspread_proto::{Availability, GameSummary};
use serde::{Deserialize, Serialize};
use tokio::{io::AsyncWriteExt, sync::Mutex};
use crate::{context::OperationKind, error::PeerError};
use crate::{
context::OperationKind,
error::PeerError,
game_paths::{
LEGACY_LIBRARY_INDEX_DIR,
LOCAL_DIR,
VERSION_INI,
is_download_protected_root_name,
is_ignored_games_root_name,
},
};
// =============================================================================
// 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");
let local_dir = path.join(LOCAL_DIR);
tokio::fs::metadata(&local_dir)
.await
.is_ok_and(|metadata| metadata.is_dir())
@@ -40,7 +40,7 @@ pub async fn local_dir_is_directory(path: &Path) -> bool {
/// 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");
let version_path = game_path.join(VERSION_INI);
tokio::fs::metadata(&version_path)
.await
.is_ok_and(|metadata| metadata.is_file())
@@ -105,11 +105,7 @@ pub async fn local_download_matches_catalog(
// 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(()));
@@ -289,7 +285,7 @@ async fn root_eti_fingerprints(game_path: &Path) -> eyre::Result<Vec<EtiFingerpr
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_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 {
@@ -317,40 +313,15 @@ async fn fingerprint_game_dir(game_path: &Path) -> eyre::Result<GameFingerprint>
})
}
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
entry
.file_name()
.to_str()
.is_some_and(is_download_protected_root_name)
}
fn canonical_protocol_path(path: &Path) -> eyre::Result<String> {
@@ -659,7 +630,7 @@ pub async fn scan_local_library(
let Some(game_id) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if is_ignored_game_root_name(game_id) {
if is_ignored_games_root_name(game_id) {
continue;
}