#![allow(clippy::missing_errors_doc)] #![allow(clippy::doc_markdown)] use std::{collections::HashMap, fmt, path::Path}; use serde::{Deserialize, Serialize}; /// Read version from version.ini file /// # Errors /// Returns error if file cannot be read or parsed pub fn read_version_from_ini(game_dir: &Path) -> eyre::Result> { let version_file = game_dir.join("version.ini"); if !version_file.exists() { return Ok(None); } let content = std::fs::read_to_string(&version_file)?; let version = content.trim().to_string(); // Validate format (YYYYMMDD) if version.len() == 8 && version.chars().all(|c| c.is_ascii_digit()) { Ok(Some(version)) } else { tracing::warn!( "Invalid version format in {}: {}", version_file.display(), version ); Ok(None) } } /// A game #[derive(Clone, Serialize, Deserialize)] pub struct Game { /// example: aoe2 pub id: String, /// example: Age of Empires 2 pub name: String, /// example: Dieses Paket enthält die original AoE 2 Version,... pub description: String, /// example: 1999 pub release_year: String, /// Microsoft pub publisher: String, /// example: 8 pub max_players: u32, /// example: 3.5 pub version: String, /// example: Echtzeit-Strategie pub genre: String, /// size in bytes: example: 3455063152 pub size: u64, /// indicates that the ETI bundle exists locally #[serde(default)] pub downloaded: bool, /// only relevant for client (yeah... I know) #[serde(default)] pub installed: bool, /// ETI game version from version.ini (YYYYMMDD format) (server) pub eti_game_version: Option, /// Local game version from version.ini (YYYYMMDD format) pub local_version: Option, /// Number of peers that have this game available pub peer_count: u32, } impl fmt::Debug for Game { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, "{}: {} ({} MB)", self.id, self.name, self.size / 1024 / 1024, ) } } impl fmt::Display for Game { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.name) } } impl PartialEq for Game { fn eq(&self, other: &Self) -> bool { self.id == other.id } } impl Eq for Game {} impl PartialOrd for Game { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for Game { fn cmp(&self, other: &Self) -> std::cmp::Ordering { self.name.cmp(&other.name) } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GameDB { pub games: HashMap, } impl GameDB { #[must_use] pub fn empty() -> Self { GameDB { games: HashMap::new(), } } #[must_use] pub fn from(games: Vec) -> Self { let mut db = GameDB::empty(); for game in games { db.games.insert(game.id.clone(), game); } db } #[must_use] pub fn get_game_by_id(&self, id: S) -> Option<&Game> where S: AsRef, { self.games.get(id.as_ref()) } #[must_use] pub fn get_mut_game_by_id(&mut self, id: S) -> Option<&mut Game> where S: AsRef, { self.games.get_mut(id.as_ref()) } #[must_use] pub fn get_game_by_name(&self, name: &str) -> Option<&Game> { self.games.values().find(|game| game.name == name) } #[must_use] pub fn all_games(&self) -> Vec<&Game> { let mut games: Vec<_> = self.games.values().collect(); games.sort_by(|a, b| a.name.cmp(&b.name)); games } pub fn set_all_uninstalled(&mut self) { for game in self.games.values_mut() { game.downloaded = false; game.installed = false; game.local_version = None; } } } impl Default for GameDB { fn default() -> Self { Self::empty() } } #[derive(Clone, Serialize, Deserialize)] pub struct GameFileDescription { pub game_id: String, pub relative_path: String, pub is_dir: bool, pub size: u64, } impl GameFileDescription { #[must_use] pub fn is_version_ini(&self) -> bool { let expected = format!("{}/version.ini", self.game_id); self.relative_path.replace('\\', "/") == expected } #[must_use] pub fn file_size(&self) -> u64 { if self.is_dir { 0 } else { self.size } } } impl fmt::Debug for GameFileDescription { #[allow(clippy::cast_precision_loss)] fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, "{}: [{}] path:{} size:{}", self.game_id, if self.is_dir { 'D' } else { 'F' }, self.relative_path, self.size, ) } } #[cfg(test)] mod tests { use serde_json::json; use super::{Game, GameFileDescription}; #[test] fn installed_defaults_to_false_when_missing() { let raw = json!({ "id": "aoe2", "name": "Age of Empires II", "description": "desc", "release_year": "1999", "publisher": "Microsoft", "max_players": 8, "version": "1.0", "genre": "RTS", "size": 123_456, "thumbnail": null, "downloaded": true, "eti_game_version": "20240101", "local_version": null, "peer_count": 2 }) .to_string(); let game: Game = serde_json::from_str(&raw).expect("game should deserialize"); assert!( !game.installed, "missing installed flag should default to false" ); } #[test] fn version_ini_predicate_matches_only_game_root_sentinel() { let root = GameFileDescription { game_id: "aoe2".to_string(), relative_path: "aoe2/version.ini".to_string(), is_dir: false, size: 8, }; assert!(root.is_version_ini()); let nested = GameFileDescription { game_id: "aoe2".to_string(), relative_path: "aoe2/local/version.ini".to_string(), is_dir: false, size: 8, }; assert!(!nested.is_version_ini()); let other_game = GameFileDescription { game_id: "aoe2".to_string(), relative_path: "other/version.ini".to_string(), is_dir: false, size: 8, }; assert!(!other_game.is_version_ini()); } }