Skip to main content

pacenotes_installer/
gamedir.rs

1//! The games the installer targets, and the executable that makes a folder one
2//! of them, as the legacy `InstallationForm.cs` checked.
3
4/// A game the installer can target, as listed in `installer/DESIGN.md` rev. 2.
5#[derive(Clone, Copy, PartialEq, Eq, Debug)]
6pub enum Game {
7    Rbr,
8    DirtRally2,
9    AssettoCorsa,
10    BeamNg,
11}
12
13impl Game {
14    /// In dropdown order.
15    pub const ALL: [Game; 4] = [
16        Game::Rbr,
17        Game::DirtRally2,
18        Game::AssettoCorsa,
19        Game::BeamNg,
20    ];
21
22    /// A proper noun, never translated.
23    pub fn title(self) -> &'static str {
24        match self {
25            Game::Rbr => "Richard Burns Rally",
26            Game::DirtRally2 => "Dirt Rally 2.0",
27            Game::AssettoCorsa => "Assetto Corsa",
28            Game::BeamNg => "BeamNG",
29        }
30    }
31
32    /// The executable identifying this game's folder; RBR and Dirt Rally keep
33    /// the legacy names so every folder that worked there still works.
34    pub fn marker(self) -> &'static str {
35        match self {
36            Game::Rbr => "RichardBurnsRally_SSE.exe",
37            Game::DirtRally2 => "dirtrally2.exe",
38            Game::AssettoCorsa => "assettocorsa.exe",
39            Game::BeamNg => "BeamNG.drive.exe",
40        }
41    }
42
43    /// The `PackageInfo::game` value the server writes for this game.
44    pub fn id(self) -> &'static str {
45        match self {
46            Game::Rbr => pacenotes_shared::GAME_RBR,
47            Game::DirtRally2 => pacenotes_shared::GAME_DIRT_RALLY2,
48            Game::AssettoCorsa => pacenotes_shared::GAME_ASSETTOCORSA,
49            Game::BeamNg => pacenotes_shared::GAME_BEAMNG,
50        }
51    }
52
53    /// `None` for an id this build does not know, whose packages are then ignored.
54    pub fn from_id(id: &str) -> Option<Game> {
55        Game::ALL.iter().copied().find(|game| game.id() == id)
56    }
57
58    pub fn index(self) -> usize {
59        Game::ALL.iter().position(|&game| game == self).unwrap_or(0)
60    }
61
62    /// Out of range falls back to the first game.
63    pub fn from_index(index: usize) -> Game {
64        Game::ALL.get(index).copied().unwrap_or(Game::Rbr)
65    }
66}
67
68/// The game-folder check's outcome; each needs a different fix from the user.
69#[derive(Clone, Copy, PartialEq, Eq, Debug)]
70pub enum DirStatus {
71    Empty,
72    /// Missing, or a file rather than a directory.
73    NotFound,
74    /// A directory without this game's executable.
75    NotGame,
76    Ok,
77}
78
79impl DirStatus {
80    pub fn is_ok(self) -> bool {
81        self == DirStatus::Ok
82    }
83}
84
85/// The games `dir` holds, in `Game::ALL` order.
86///
87/// Matches executable names case-insensitively, because publishers disagree
88/// on case and NTFS would hide a mismatch only on Windows.
89pub fn detect(dir: &str) -> Vec<Game> {
90    let trimmed = dir.trim();
91    if trimmed.is_empty() {
92        return Vec::new();
93    }
94    let Ok(entries) = std::fs::read_dir(trimmed) else {
95        return Vec::new();
96    };
97
98    let file_names: Vec<String> = entries
99        .flatten()
100        .filter(|entry| entry.file_type().is_ok_and(|file_type| file_type.is_file()))
101        .map(|entry| entry.file_name().to_string_lossy().to_lowercase())
102        .collect();
103
104    Game::ALL
105        .iter()
106        .copied()
107        .filter(|game| file_names.contains(&game.marker().to_lowercase()))
108        .collect()
109}
110
111/// Ignores surrounding whitespace, which pasted paths routinely carry.
112pub fn validate(game: Game, dir: &str) -> DirStatus {
113    let trimmed = dir.trim();
114    if trimmed.is_empty() {
115        return DirStatus::Empty;
116    }
117    if !std::path::Path::new(trimmed).is_dir() {
118        return DirStatus::NotFound;
119    }
120    if detect(trimmed).contains(&game) {
121        DirStatus::Ok
122    } else {
123        DirStatus::NotGame
124    }
125}
126
127#[cfg(test)]
128mod tests;