Skip to main content

modde_games/generic/
manage.rs

1//! Managing user-defined game specs on disk: add, remove, read, and detect
2//! candidate install directories.
3
4use std::fmt;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use anyhow::{Context, Result, ensure};
9use serde::Serialize;
10use tracing::warn;
11
12use modde_core::paths;
13
14use super::spec::GameSpec;
15
16/// Outcome of [`add_user_game`]: where the spec was written and whether it existed already.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct AddUserGameResult {
19    pub path: PathBuf,
20    pub existed: bool,
21}
22
23/// A candidate game directory found by [`detect_candidates`], with its
24/// executables and total size to help rank likely game roots.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct DetectCandidateDir {
27    pub relative_dir: String,
28    pub exe_names: Vec<String>,
29    pub total_size: u64,
30}
31
32impl fmt::Display for DetectCandidateDir {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        if self.exe_names.is_empty() {
35            f.write_str(&self.relative_dir)
36        } else {
37            write!(f, "{} ({})", self.relative_dir, self.exe_names.join(", "))
38        }
39    }
40}
41
42#[derive(Serialize)]
43struct GameSpecToml<'a> {
44    id: &'a str,
45    display_name: &'a str,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    steam_app_id: Option<&'a str>,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    install_dir_name: Option<&'a str>,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    install_path_override: Option<String>,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    mod_dir: Option<String>,
54    executable_dir: String,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    nexus_domain: Option<&'a str>,
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    proxy_dlls: Vec<&'a str>,
59}
60
61/// Directory where user-defined game specs are stored.
62#[must_use]
63pub fn games_dir() -> PathBuf {
64    paths::modde_data_dir().join("games")
65}
66
67/// Path to the TOML spec file for the user game with the given `id`.
68#[must_use]
69pub fn user_game_path(id: &str) -> PathBuf {
70    games_dir().join(format!("{id}.toml"))
71}
72
73/// Render a path as a forward-slash string suitable for embedding in TOML.
74#[must_use]
75pub fn path_to_toml_string(path: &Path) -> String {
76    let parts: Vec<String> = path
77        .components()
78        .map(|component| component.as_os_str().to_string_lossy().into_owned())
79        .collect();
80    if parts.is_empty() {
81        ".".to_string()
82    } else {
83        parts.join("/")
84    }
85}
86
87fn game_spec_to_toml(spec: &GameSpec) -> GameSpecToml<'_> {
88    GameSpecToml {
89        id: &spec.id,
90        display_name: &spec.display_name,
91        steam_app_id: spec.steam_app_id.as_deref(),
92        install_dir_name: spec.install_dir_name.as_deref(),
93        install_path_override: spec
94            .install_path_override
95            .as_ref()
96            .map(|path| path_to_toml_string(path)),
97        mod_dir: spec.mod_dir.as_ref().map(|path| path_to_toml_string(path)),
98        executable_dir: path_to_toml_string(&spec.executable_dir),
99        nexus_domain: spec.nexus_domain.as_deref(),
100        proxy_dlls: spec.proxy_dlls.iter().map(String::as_str).collect(),
101    }
102}
103
104pub fn read_user_game_spec(id: &str) -> Result<Option<(PathBuf, GameSpec)>> {
105    let path = user_game_path(id);
106    if !path.exists() {
107        return Ok(None);
108    }
109    let content =
110        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
111    let spec: GameSpec =
112        toml::from_str(&content).with_context(|| format!("failed to parse {}", path.display()))?;
113    spec.validate()
114        .with_context(|| format!("invalid spec in {}", path.display()))?;
115    Ok(Some((path, spec)))
116}
117
118pub fn add_user_game(spec: &GameSpec, force: bool) -> Result<AddUserGameResult> {
119    spec.validate()
120        .with_context(|| format!("invalid game spec for '{}'", spec.id))?;
121
122    let path = user_game_path(&spec.id);
123    let existed = path.exists();
124    if existed && !force {
125        anyhow::bail!(
126            "game '{}' already exists at {}. Re-run with --force to overwrite.",
127            spec.id,
128            path.display()
129        );
130    }
131
132    fs::create_dir_all(games_dir()).with_context(|| "failed to create user games directory")?;
133    let rendered = toml::to_string_pretty(&game_spec_to_toml(spec))
134        .context("failed to serialize game spec to TOML")?;
135    fs::write(&path, rendered).with_context(|| format!("failed to write {}", path.display()))?;
136
137    Ok(AddUserGameResult { path, existed })
138}
139
140pub fn remove_user_game(id: &str) -> Result<PathBuf> {
141    let path = user_game_path(id);
142    if !path.exists() {
143        if crate::resolve_game(id).is_some() {
144            anyhow::bail!("game '{id}' is built in and cannot be removed");
145        }
146        anyhow::bail!("no user-defined game named '{id}' exists");
147    }
148
149    fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?;
150    Ok(path)
151}
152
153pub fn detect_candidates(install_path: &Path) -> Result<Vec<DetectCandidateDir>> {
154    ensure!(
155        install_path.is_dir(),
156        "install path does not exist: {}",
157        install_path.display()
158    );
159
160    let mut candidates = Vec::new();
161    walk_exe_dirs(install_path, install_path, 0, &mut candidates)?;
162    candidates.sort_by(|a, b| {
163        b.total_size
164            .cmp(&a.total_size)
165            .then_with(|| a.relative_dir.cmp(&b.relative_dir))
166    });
167    Ok(candidates)
168}
169
170fn walk_exe_dirs(
171    root: &Path,
172    dir: &Path,
173    depth: usize,
174    candidates: &mut Vec<DetectCandidateDir>,
175) -> Result<()> {
176    let entries = fs::read_dir(dir)
177        .with_context(|| format!("failed to read directory: {}", dir.display()))?;
178
179    let mut exe_names = Vec::new();
180    let mut total_size = 0u64;
181    let mut subdirs = Vec::new();
182
183    for entry in entries.flatten() {
184        let path = entry.path();
185        let file_type = match entry.file_type() {
186            Ok(file_type) => file_type,
187            Err(error) => {
188                warn!(path = %path.display(), error = %error, "skipping unreadable entry");
189                continue;
190            }
191        };
192
193        if file_type.is_dir() {
194            if depth < 4 {
195                subdirs.push(path);
196            }
197            continue;
198        }
199
200        if !file_type.is_file() {
201            continue;
202        }
203
204        if path
205            .extension()
206            .and_then(|ext| ext.to_str())
207            .is_some_and(|ext| ext.eq_ignore_ascii_case("exe"))
208        {
209            let size = entry.metadata().map(|metadata| metadata.len()).unwrap_or(0);
210            total_size += size;
211            exe_names.push(
212                path.file_name()
213                    .map(|name| name.to_string_lossy().into_owned())
214                    .unwrap_or_else(|| path.display().to_string()),
215            );
216        }
217    }
218
219    if !exe_names.is_empty() {
220        exe_names.sort();
221        candidates.push(DetectCandidateDir {
222            relative_dir: relative_dir(root, dir),
223            exe_names,
224            total_size,
225        });
226    }
227
228    for subdir in subdirs {
229        walk_exe_dirs(root, &subdir, depth + 1, candidates)?;
230    }
231
232    Ok(())
233}
234
235fn relative_dir(root: &Path, dir: &Path) -> String {
236    let rel = dir.strip_prefix(root).unwrap_or(dir);
237    if rel.as_os_str().is_empty() {
238        ".".to_string()
239    } else {
240        rel.to_string_lossy().into_owned()
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn detect_candidates_prefers_larger_executable_dirs() {
250        let temp = tempfile::tempdir().expect("tempdir");
251        let install = temp.path();
252        let game = install.join("Game");
253        let support = install.join("Support");
254        fs::create_dir_all(&game).expect("game dir");
255        fs::create_dir_all(&support).expect("support dir");
256        fs::write(game.join("game.exe"), vec![0u8; 8]).expect("write game exe");
257        fs::write(support.join("launcher.exe"), vec![0u8; 2]).expect("write support exe");
258
259        let candidates = detect_candidates(install).expect("detect candidates");
260
261        assert_eq!(candidates.len(), 2);
262        assert_eq!(candidates[0].relative_dir, "Game");
263        assert_eq!(candidates[1].relative_dir, "Support");
264    }
265}