Skip to main content

game_manager_lib/sources/
scanner.rs

1//! Módulo para escanear diretórios de jogos localmente.
2//!
3//! Este módulo implementa um scanner inteligente para detectar jogos instalados
4//! localmente, identificando executáveis candidatos e permitindo seleção manual.
5//! Ele é otimizado para jogos indie/antigos/locais, não dependendo de lojas oficiais.
6//!
7//! **Funcionalidades principais:**
8//! - Escaneamento recursivo de pastas
9//! - Detecção heurística de executáveis de jogos
10//! - Ranking baseado em nome, tamanho e localização
11//! - Suporte cross-platform (Windows e Linux)
12//! - Foco em jogos indie/antigos/portados
13
14use 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
28// === STRUCTS INTERNAS (NÃO REFLETEM O BANCO DE DADOS) ===
29
30pub struct ScanSource {
31    pub folder_path: String,
32}
33
34/// Representa uma sessão de escaneamento de pastas
35#[derive(Debug, Clone)]
36pub struct ScanSession {
37    pub id: String,
38    pub root_path: PathBuf,
39    pub started_at: SystemTime,
40}
41
42/// Representa um possível jogo descoberto durante o scan
43#[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/// Representa um executável candidato a ser o launcher do jogo
55#[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/// Tipo de executável detectado
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67pub enum ExecutableType {
68    WindowsExe,
69    LinuxElf,
70    Script,
71    Unknown,
72}
73
74// === FUNÇÕES PRINCIPAIS DE SCAN ===
75
76/// Escaneia uma pasta raiz procurando por subpastas que contenham jogos
77///
78/// **Argumentos:**
79/// * `root` - Caminho da pasta raiz para escanear (ex: "C:\Games" ou "/home/user/games")
80///
81/// **Retorna:**
82/// * `Ok(Vec<GameDiscovery>)` - Lista de possíveis jogos encontrados
83/// * `Err(String)` - Mensagem de erro se houver falha na leitura
84pub 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    // Rust cria uma thread pool automaticamente
91    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            // Cada pasta é escaneada numa thread diferente simultaneamente
97            scan_game_folder(session_id, &entry.path()).ok().flatten()
98        })
99        .collect();
100
101    Ok(discoveries)
102}
103
104/// Escaneia uma pasta individual procurando por executáveis de jogos
105///
106/// **Argumentos:**
107/// * `session_id` - ID da sessão de scan atual
108/// * `folder` - Pasta a ser escaneada
109///
110/// **Retorna:**
111/// * `Ok(Some(GameDiscovery))` - Se encontrou executáveis candidatos
112/// * `Ok(None)` - Se não encontrou nenhum executável
113/// * `Err(String)` - Se houve erro na leitura
114fn 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    // Busca recursivamente por executáveis (profundidade limitada)
119    scan_executables_recursive(folder, &mut executables, 0, &mut total_processed)?;
120
121    // Se não encontrou nenhum executável, esta pasta não é um jogo
122    if executables.is_empty() {
123        return Ok(None);
124    }
125
126    // Usa o nome da pasta como nome sugerido do jogo
127    let folder_name = folder
128        .file_name()
129        .and_then(|n| n.to_str())
130        .unwrap_or("Unknown")
131        .to_string();
132
133    // Calcula confiança baseada no melhor score dos executáveis
134    let confidence = executables.iter().map(|e| e.rank_score).max().unwrap_or(0);
135
136    // Seleciona melhor executável como principal
137    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
154/// Busca recursivamente por executáveis em uma pasta
155///
156/// **Argumentos:**
157/// * `dir` - Diretório atual sendo escaneado
158/// * `out` - Vetor onde os candidatos encontrados serão adicionados
159/// * `depth` - Profundidade atual da recursão
160/// * `total_processed` - Contador global de arquivos processados
161///
162/// **Limitações:**
163/// * Máximo de 4 níveis de profundidade para evitar scans muito longos
164/// * Máximo de 1000 arquivos por diretório
165/// * Máximo de 10000 arquivos totais processados
166fn scan_executables_recursive(
167    dir: &Path,
168    out: &mut Vec<ExecutableCandidate>,
169    depth: usize,
170    total_processed: &mut usize,
171) -> Result<(), AppError> {
172    // Limita profundidade para evitar scans infinitos ou muito lentos
173    if depth > SCANNER_MAX_DEPTH {
174        return Ok(());
175    }
176
177    // Verifica limite global
178    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) // Limita arquivos por pasta
185        .flatten()
186        .collect();
187
188    for entry in entries {
189        let path = entry.path();
190
191        // Incrementa contador
192        *total_processed += 1;
193
194        // Verifica limite global novamente
195        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        // Se for diretório, escaneia recursivamente
204        if path.is_dir() {
205            scan_executables_recursive(&path, out, depth + 1, total_processed)?;
206            continue;
207        }
208
209        // Se for arquivo, analisa se é um executável candidato
210        if let Some(candidate) = analyze_file(&path)? {
211            out.push(candidate);
212        }
213    }
214
215    Ok(())
216}
217
218// === ANÁLISE DE ARQUIVOS ===
219
220/// Analisa um arquivo para determinar se é um executável candidato
221///
222/// **Heurísticas aplicadas:**
223/// * Tamanho mínimo: 5MB (evita ferramentas pequenas)
224/// * Tipo de executável: .exe no Windows, ELF com bit de execução no Linux
225/// * Score de ranking baseado em nome e tamanho
226///
227/// **Retorna:**
228/// * `Ok(Some(ExecutableCandidate))` - Se o arquivo é um candidato válido
229/// * `Ok(None)` - Se o arquivo deve ser ignorado
230/// * `Err(String)` - Se houve erro ao analisar
231fn 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    // Detecta tipo de executável (cross-platform)
242    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    // Calcula score de ranking para ordenação
251    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
262// === DETECÇÃO DE TIPO DE EXECUTÁVEL (CROSS-PLATAFORM) ===
263
264/// Detecta o tipo de executável baseado na plataforma atual
265///
266/// **Windows:**
267/// * Verifica extensão .exe
268///
269/// **Linux:**
270/// * Verifica bit de execução (chmod +x)
271/// * Idealmente deveria verificar ELF header, mas por simplicidade usa apenas permissões
272fn detect_executable_type(path: &Path) -> Result<ExecutableType, AppError> {
273    // Windows: verifica extensão .exe
274    #[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    // Linux/Unix: verifica bit de execução
285    #[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        // Verifica se tem bit de execução (owner, group ou other)
293        if permissions.mode() & 0o111 != 0 {
294            // Poderia verificar se é script (.sh) ou ELF binário
295            // Por enquanto, assume ELF
296            return Ok(ExecutableType::LinuxElf);
297        }
298
299        return Ok(ExecutableType::Unknown);
300    }
301
302    // Outras plataformas não suportadas
303    #[cfg(not(any(windows, unix)))]
304    {
305        Ok(ExecutableType::Unknown)
306    }
307}
308
309// === RANKING DE EXECUTÁVEIS ===
310
311/// Calcula um score de ranking para priorizar executáveis
312///
313/// **Sistema de pontos:**
314/// * -5: Nomes suspeitos (launcher, crash, uninstall, setup, redist)
315/// * +2: Nomes positivos (game, play, start)
316/// * +3: Nome similar ao nome da pasta pai
317/// * +2: Executável na raiz da pasta do jogo
318///
319/// **Contexto:**
320/// Esta heurística é otimizada para jogos indie/antigos/locais.
321fn calculate_rank(filename: &str, path: &Path) -> i32 {
322    let mut score = 0;
323
324    let name_lower = filename.to_lowercase();
325
326    // === PENALIDADES ===
327
328    // Arquivos que certamente NÃO são o jogo principal
329    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    // === BÔNUS ===
357
358    // Nomes que sugerem ser o executável principal
359    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    // Bônus se o nome do arquivo é similar ao nome da pasta
369    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            // Verifica similaridade básica
378            if folder_str.contains(&file_stem) || file_stem.contains(&folder_str) {
379                score += 3;
380            }
381        }
382    }
383
384    // Bônus se está na raiz da pasta do jogo (não em subpastas)
385    if path.parent().and_then(|p| p.file_name()).is_some() {
386        // Conta níveis de profundidade
387        let depth = path.ancestors().count();
388        if depth <= 3 {
389            // Raiz ou nível 1
390            score += 2;
391        }
392    }
393
394    score
395}
396
397// === FUNÇÕES AUXILIARES ===
398
399impl GameDiscovery {
400    /// Retorna o executável com maior ranking
401    pub fn best_executable(&self) -> Option<&ExecutableCandidate> {
402        self.executables.iter().max_by_key(|e| e.rank_score)
403    }
404
405    /// Ordena executáveis por ranking (maior primeiro)
406    /// Retorna referências para evitar clonagem desnecessária
407    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    /// Versão que retorna owned (se necessário para serialização)
414    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/// Implementação do GameSource para ScanSource, permitindo integração com o sistema de fontes de jogos
433#[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}