modde_games/generic/
manage.rs1use 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#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct AddUserGameResult {
19 pub path: PathBuf,
20 pub existed: bool,
21}
22
23#[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#[must_use]
63pub fn games_dir() -> PathBuf {
64 paths::modde_data_dir().join("games")
65}
66
67#[must_use]
69pub fn user_game_path(id: &str) -> PathBuf {
70 games_dir().join(format!("{id}.toml"))
71}
72
73#[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}