game_manager_lib/sources/
scanner.rs1use crate::constants::{
15 BYTES_PER_MB, SCANNER_MAX_DEPTH, SCANNER_MAX_FILES_PER_DIR, SCANNER_MAX_TOTAL_FILES,
16};
17use crate::errors::AppError;
18use crate::sources::providers::{GameSource, SourceGame};
19use async_trait::async_trait;
20use rayon::prelude::*;
21use serde::{Deserialize, Serialize};
22use std::fs;
23#[cfg(unix)]
24use std::os::unix::fs::PermissionsExt;
25use std::path::{Path, PathBuf};
26use std::time::SystemTime;
27
28pub struct ScanSource {
31 pub folder_path: String,
32}
33
34#[derive(Debug, Clone)]
36pub struct ScanSession {
37 pub id: String,
38 pub root_path: PathBuf,
39 pub started_at: SystemTime,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44#[serde(rename_all = "camelCase")]
45pub struct GameDiscovery {
46 pub id: String,
47 pub base_path: String,
48 pub executable_path: String,
49 pub suggested_name: String,
50 pub confidence: i32,
51 pub executables: Vec<ExecutableCandidate>,
52}
53
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
56#[serde(rename_all = "camelCase")]
57pub struct ExecutableCandidate {
58 pub path: String,
59 pub filename: String,
60 pub size_mb: u64,
61 pub rank_score: i32,
62 pub executable_type: ExecutableType,
63}
64
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67pub enum ExecutableType {
68 WindowsExe,
69 LinuxElf,
70 Script,
71 Unknown,
72}
73
74pub fn scan_folder(root: &Path) -> Result<Vec<GameDiscovery>, AppError> {
85 let entries: Vec<_> = fs::read_dir(root)
86 .map_err(|e| AppError::ScanReadRootError(e.to_string()))?
87 .flatten()
88 .collect();
89
90 let discoveries: Vec<GameDiscovery> = entries
92 .par_iter()
93 .filter(|entry| entry.path().is_dir())
94 .filter_map(|entry| {
95 let session_id = "par_session";
96 scan_game_folder(session_id, &entry.path()).ok().flatten()
98 })
99 .collect();
100
101 Ok(discoveries)
102}
103
104fn scan_game_folder(_session_id: &str, folder: &Path) -> Result<Option<GameDiscovery>, String> {
115 let mut executables = Vec::new();
116 let mut total_processed = 0;
117
118 scan_executables_recursive(folder, &mut executables, 0, &mut total_processed)?;
120
121 if executables.is_empty() {
123 return Ok(None);
124 }
125
126 let folder_name = folder
128 .file_name()
129 .and_then(|n| n.to_str())
130 .unwrap_or("Unknown")
131 .to_string();
132
133 let confidence = executables.iter().map(|e| e.rank_score).max().unwrap_or(0);
135
136 let best_executable = executables
138 .iter()
139 .max_by_key(|e| e.rank_score)
140 .filter(|e| !e.path.is_empty())
141 .map(|e| e.path.clone())
142 .unwrap_or_default();
143
144 Ok(Some(GameDiscovery {
145 id: uuid::Uuid::new_v4().to_string(),
146 base_path: folder.to_string_lossy().to_string(),
147 executable_path: best_executable,
148 suggested_name: folder_name,
149 confidence,
150 executables,
151 }))
152}
153
154fn scan_executables_recursive(
167 dir: &Path,
168 out: &mut Vec<ExecutableCandidate>,
169 depth: usize,
170 total_processed: &mut usize,
171) -> Result<(), AppError> {
172 if depth > SCANNER_MAX_DEPTH {
174 return Ok(());
175 }
176
177 if *total_processed >= SCANNER_MAX_TOTAL_FILES {
179 return Err(AppError::ScanFileLimitReached(SCANNER_MAX_TOTAL_FILES));
180 }
181
182 let entries: Vec<_> = fs::read_dir(dir)
183 .map_err(|e| AppError::ScanReadDirError(dir.display().to_string(), e.to_string()))?
184 .take(SCANNER_MAX_FILES_PER_DIR) .flatten()
186 .collect();
187
188 for entry in entries {
189 let path = entry.path();
190
191 *total_processed += 1;
193
194 if *total_processed >= SCANNER_MAX_TOTAL_FILES {
196 return Err(AppError::ScanFileLimitReached(SCANNER_MAX_TOTAL_FILES));
197 }
198
199 if path.is_symlink() {
200 continue;
201 }
202
203 if path.is_dir() {
205 scan_executables_recursive(&path, out, depth + 1, total_processed)?;
206 continue;
207 }
208
209 if let Some(candidate) = analyze_file(&path)? {
211 out.push(candidate);
212 }
213 }
214
215 Ok(())
216}
217
218fn analyze_file(path: &Path) -> Result<Option<ExecutableCandidate>, AppError> {
232 let metadata = fs::metadata(path)
233 .map_err(|e| AppError::ScanMetadataError(path.display().to_string(), e.to_string()))?;
234
235 let filename = path
236 .file_name()
237 .ok_or_else(|| AppError::ValidationError("Nome de arquivo inválido".to_string()))?
238 .to_string_lossy()
239 .to_string();
240
241 let executable_type = detect_executable_type(path)?;
243
244 if executable_type == ExecutableType::Unknown {
245 return Ok(None);
246 }
247
248 let size_mb = metadata.len() / BYTES_PER_MB;
249
250 let rank_score = calculate_rank(&filename, path);
252
253 Ok(Some(ExecutableCandidate {
254 path: path.to_string_lossy().to_string(),
255 filename,
256 size_mb,
257 rank_score,
258 executable_type,
259 }))
260}
261
262fn detect_executable_type(path: &Path) -> Result<ExecutableType, AppError> {
273 #[cfg(windows)]
275 {
276 if let Some(ext) = path.extension() {
277 if ext.eq_ignore_ascii_case("exe") {
278 return Ok(ExecutableType::WindowsExe);
279 }
280 }
281 Ok(ExecutableType::Unknown)
282 }
283
284 #[cfg(unix)]
286 {
287 let metadata =
288 fs::metadata(path).map_err(|e| AppError::ScanPermissionsError(e.to_string()))?;
289
290 let permissions = metadata.permissions();
291
292 if permissions.mode() & 0o111 != 0 {
294 return Ok(ExecutableType::LinuxElf);
297 }
298
299 return Ok(ExecutableType::Unknown);
300 }
301
302 #[cfg(not(any(windows, unix)))]
304 {
305 Ok(ExecutableType::Unknown)
306 }
307}
308
309fn calculate_rank(filename: &str, path: &Path) -> i32 {
322 let mut score = 0;
323
324 let name_lower = filename.to_lowercase();
325
326 let bad_keywords = [
330 "launcher",
331 "crash",
332 "uninstall",
333 "uninst",
334 "setup",
335 "redist",
336 "vcredist",
337 "dx",
338 "directx",
339 "reporter",
340 "updater",
341 "patcher",
342 "config",
343 "settings",
344 "handler",
345 "helper",
346 "unins000",
347 ];
348
349 for keyword in &bad_keywords {
350 if name_lower.contains(keyword) {
351 score -= 5;
352 break;
353 }
354 }
355
356 let good_keywords = ["game", "play", "start", "run"];
360
361 for keyword in &good_keywords {
362 if name_lower.contains(keyword) {
363 score += 2;
364 break;
365 }
366 }
367
368 if let Some(parent) = path.parent() {
370 if let Some(folder_name) = parent.file_name() {
371 let folder_str = folder_name.to_string_lossy().to_lowercase();
372 let file_stem = path
373 .file_stem()
374 .map(|s| s.to_string_lossy().to_lowercase())
375 .unwrap_or_default();
376
377 if folder_str.contains(&file_stem) || file_stem.contains(&folder_str) {
379 score += 3;
380 }
381 }
382 }
383
384 if path.parent().and_then(|p| p.file_name()).is_some() {
386 let depth = path.ancestors().count();
388 if depth <= 3 {
389 score += 2;
391 }
392 }
393
394 score
395}
396
397impl GameDiscovery {
400 pub fn best_executable(&self) -> Option<&ExecutableCandidate> {
402 self.executables.iter().max_by_key(|e| e.rank_score)
403 }
404
405 pub fn sorted_executables(&self) -> Vec<&ExecutableCandidate> {
408 let mut refs: Vec<&ExecutableCandidate> = self.executables.iter().collect();
409 refs.sort_by(|a, b| b.rank_score.cmp(&a.rank_score));
410 refs
411 }
412
413 pub fn sorted_executables_owned(&self) -> Vec<ExecutableCandidate> {
415 let mut sorted = self.executables.clone();
416 sorted.sort_by(|a, b| b.rank_score.cmp(&a.rank_score));
417 sorted
418 }
419}
420
421impl std::fmt::Display for ExecutableType {
422 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
423 match self {
424 ExecutableType::WindowsExe => write!(f, "Windows EXE"),
425 ExecutableType::LinuxElf => write!(f, "Linux ELF"),
426 ExecutableType::Script => write!(f, "Script"),
427 ExecutableType::Unknown => write!(f, "Unknown"),
428 }
429 }
430}
431
432#[async_trait]
434impl GameSource for ScanSource {
435 async fn fetch_games(&self) -> Result<Vec<SourceGame>, AppError> {
436 let path = Path::new(&self.folder_path);
437
438 if !path.exists() || !path.is_dir() {
439 return Err(AppError::ScanInvalidFolder);
440 }
441
442 let discoveries = scan_folder(path)?;
443
444 let games = discoveries
445 .into_iter()
446 .map(|game| SourceGame {
447 platform: "Outra".to_string(),
448 platform_game_id: game.executable_path.clone(),
449 name: Some(game.suggested_name),
450 installed: true,
451 executable_path: Some(game.executable_path),
452 install_path: Some(game.base_path),
453 playtime_minutes: Some(0),
454 last_played: None,
455 })
456 .collect();
457
458 Ok(games)
459 }
460}