game_manager_lib\commands/
games.rs

1//! Módulo de gerenciamento da biblioteca de jogos.
2//!
3//! Implementa operações CRUD para jogos.
4//! Inclui validações robustas e manipulação de erros para garantir integridade dos dados.
5
6use crate::constants;
7use crate::database;
8use crate::database::AppState;
9use crate::errors::AppError;
10use crate::models;
11use crate::utils::status_logic;
12use chrono::Utc;
13use rusqlite::{params, OptionalExtension};
14use serde::Deserialize;
15use tauri::State;
16use url::Url;
17
18/// Dados de entrada para criar ou atualizar um jogo.
19///
20/// Reflete os campos da ‘interface’ de adição/edição de jogos.
21#[derive(Debug, Deserialize)]
22pub struct GameInput {
23    pub id: String,
24    pub name: String,
25    pub platform: Option<String>,
26    #[serde(rename = "coverUrl")]
27    pub cover_url: Option<String>,
28    pub playtime: Option<i32>,
29    #[serde(rename = "userRating")]
30    pub user_rating: Option<i32>,
31    pub status: Option<String>,
32    #[serde(rename = "installPath")]
33    pub install_path: Option<String>,
34    #[serde(rename = "executablePath")]
35    pub executable_path: Option<String>,
36    #[serde(rename = "launchArgs")]
37    pub launch_args: Option<String>,
38}
39
40/// Dados de entrada para atualizar detalhes adicionais do jogo.
41///
42/// Usado para atualizar a tabela 'game_details'.
43#[derive(serde::Deserialize)]
44pub struct UpdateGameDetailsInput {
45    pub id: String,
46    pub description: Option<String>, // Salva na descrição PT-BR
47    pub developer: Option<String>,
48    pub publisher: Option<String>,
49    pub released: Option<String>,
50}
51
52/// Função auxiliar privada para validar dados de entrada.
53///
54/// Evita duplicação de código entre add e ‘update’.
55/// Valida nome, URL da capa, plataforma, tempo jogado e avaliação.
56fn validate_input(game: &GameInput) -> Result<(), AppError> {
57    if game.name.trim().is_empty() {
58        return Err(AppError::ValidationError(
59            "Nome do jogo não pode ser vazio".to_string(),
60        ));
61    }
62
63    if game.name.len() > constants::MAX_NAME_LENGTH {
64        return Err(AppError::ValidationError(format!(
65            "Nome muito longo (max {})",
66            constants::MAX_NAME_LENGTH
67        )));
68    }
69
70    if let Some(ref url_str) = game.cover_url {
71        if url_str.len() > constants::MAX_URL_LENGTH {
72            return Err(AppError::ValidationError(format!(
73                "URL da capa muito longa (máximo {} caracteres)",
74                constants::MAX_URL_LENGTH
75            )));
76        }
77        // Validação básica de URL
78        if !url_str.starts_with("http") && !url_str.starts_with("asset://") {
79            let url = Url::parse(url_str)
80                .map_err(|_| AppError::ValidationError("URL inválida.".to_string()))?;
81            if url.scheme() != "http" && url.scheme() != "https" {
82                return Err(AppError::ValidationError(
83                    "A URL deve ser HTTP, HTTPS ou Asset local.".to_string(),
84                ));
85            }
86        }
87    }
88
89    if let Some(ref p) = game.platform {
90        if p.len() > constants::MAX_PLATFORM_LENGTH {
91            return Err(AppError::ValidationError(format!(
92                "Plataforma muito longa (max {})",
93                constants::MAX_PLATFORM_LENGTH
94            )));
95        }
96    }
97
98    if let Some(time) = game.playtime {
99        if time < 0 {
100            return Err(AppError::ValidationError(
101                "Tempo jogado não pode ser negativo".to_string(),
102            ));
103        }
104        if time > constants::MAX_PLAYTIME {
105            return Err(AppError::ValidationError(
106                "Tempo jogado excessivo".to_string(),
107            ));
108        }
109    }
110
111    if let Some(r) = game.user_rating {
112        if !(constants::MIN_RATING..=constants::MAX_RATING).contains(&r) {
113            return Err(AppError::ValidationError(format!(
114                "Avaliação deve estar entre {} e {}",
115                constants::MIN_RATING,
116                constants::MAX_RATING
117            )));
118        }
119    }
120
121    Ok(())
122}
123
124/// Adiciona um novo jogo à biblioteca.
125///
126/// Insere dados na tabela 'games' após as validações necessárias.
127#[tauri::command]
128pub fn add_game(state: State<AppState>, game: GameInput) -> Result<(), AppError> {
129    validate_input(&game)?;
130
131    let conn = state.library_db.lock()?;
132
133    // Verifica duplicidade
134    let exists: bool = conn.query_row(
135        "SELECT EXISTS(SELECT 1 FROM games WHERE id = ?1)",
136        params![game.id],
137        |row| row.get(0),
138    )?;
139
140    if exists {
141        return Err(AppError::AlreadyExists(
142            "Já existe um jogo com este ID".to_string(),
143        ));
144    }
145
146    // Lógica Automática de Status
147    let final_status = game
148        .status
149        .unwrap_or_else(|| status_logic::calculate_status(game.playtime.unwrap_or(0)));
150
151    let added_at = Utc::now().to_rfc3339();
152    let platform = game.platform.unwrap_or("Manual".to_string());
153
154    conn.execute(
155        "INSERT INTO games (
156            id, name, cover_url, platform, platform_id, install_path, executable_path, launch_args,
157            user_rating, status, playtime, added_at
158        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
159        params![
160            game.id,
161            game.name,
162            game.cover_url,
163            platform,
164            Option::<String>::None, // Platform ID null para manual
165            game.install_path,
166            game.executable_path,
167            game.launch_args,
168            game.user_rating,
169            final_status,
170            game.playtime,
171            added_at
172        ],
173    )?;
174
175    Ok(())
176}
177
178/// Atualiza informações de um jogo existente.
179///
180/// Atualiza os campos, preservando added_at e favorite, com os novos valores fornecidos.
181/// Realiza as mesmas validações de 'add_game'.
182///
183/// **Nota:** Não retorna erro se 'ID' não existe ('update' silencioso).
184#[tauri::command]
185pub fn update_game(state: State<AppState>, game: GameInput) -> Result<(), AppError> {
186    validate_input(&game)?;
187
188    let conn = state.library_db.lock()?;
189
190    conn.execute(
191        "UPDATE games SET
192            name = ?1,
193            cover_url = ?2,
194            platform = ?3,
195            playtime = ?4,
196            user_rating = ?5,
197            status = ?6,
198            install_path = ?7,
199            executable_path = ?8,
200            launch_args = ?9
201         WHERE id = ?10",
202        params![
203            game.name,
204            game.cover_url,
205            game.platform,
206            game.playtime,
207            game.user_rating,
208            game.status,
209            game.install_path,
210            game.executable_path,
211            game.launch_args,
212            game.id
213        ],
214    )?;
215
216    Ok(())
217}
218
219/// Recupera todos os jogos da biblioteca.
220///
221/// Retorna a lista completa de jogos ordenada conforme armazenada no banco.
222/// Inclui todos os campos, inclusive o status de favorito.
223#[tauri::command]
224pub fn get_games(state: State<AppState>) -> Result<Vec<models::Game>, AppError> {
225    let conn = state.library_db.lock()?;
226
227    let mut stmt = conn
228        .prepare(
229            "SELECT
230            g.id, g.name, g.cover_url, g.platform, g.platform_id, g.install_path, g.executable_path,
231            g.launch_args, g.user_rating, g.favorite, g.status, g.playtime, g.last_played, g.added_at,
232            gd.genres, gd.developer, COALESCE(gd.is_adult, 0) as is_adult -- Campos da tabela game_details
233         FROM games g
234         LEFT JOIN game_details gd ON g.id = gd.game_id
235         ORDER BY g.name ASC"
236        )?;
237
238    let games = stmt
239        .query_map([], |row| {
240            Ok(models::Game {
241                id: row.get(0)?,
242                name: row.get(1)?,
243                cover_url: row.get(2)?,
244                platform: row.get(3)?,
245                platform_id: row.get(4)?,
246                install_path: row.get(5)?,
247                executable_path: row.get(6)?,
248                launch_args: row.get(7)?,
249                user_rating: row.get(8)?,
250                favorite: row.get(9)?,
251                status: row.get(10)?,
252                playtime: row.get(11)?,
253                last_played: row.get(12)?,
254                added_at: row.get(13)?,
255                genres: row.get(14)?,
256                developer: row.get(15)?,
257                is_adult: row.get(16)?,
258            })
259        })?
260        .collect::<Result<Vec<_>, _>>()?;
261
262    Ok(games)
263}
264
265/// Recupera detalhes adicionais de um jogo na biblioteca.
266///
267/// Busca na tabela 'game_details' usando o game_id fornecido.
268/// Usado para obter informações adicionais sobre o jogo que serão exibidas na ‘interface’.
269/// Retorna None se não houver detalhes para o jogo.
270#[tauri::command]
271pub fn get_library_game_details(
272    state: State<AppState>,
273    game_id: String,
274) -> Result<Option<models::GameDetails>, AppError> {
275    let conn = state.library_db.lock()?;
276
277    let mut stmt = conn.prepare(
278        "SELECT
279                game_id, steam_app_id, developer, publisher, release_date, genres, tags, series,
280                description_raw, description_ptbr, background_image, critic_score,
281                steam_review_label, steam_review_count, steam_review_score, steam_review_updated_at,
282                esrb_rating, is_adult, adult_tags, external_links, median_playtime,
283                estimated_playtime
284             FROM game_details
285             WHERE game_id = ?1",
286    )?;
287
288    let mut rows = stmt.query_map(params![game_id], |row| {
289        let links_json: Option<String> = row.get(19)?; // external_links
290        let external_links = links_json.and_then(|json| serde_json::from_str(&json).ok());
291
292        let tags_json: Option<String> = row.get(6)?;
293        let tags = tags_json.map(|s| database::deserialize_tags(&s));
294
295        Ok(models::GameDetails {
296            game_id: row.get(0)?,
297            steam_app_id: row.get(1)?,
298            developer: row.get(2)?,
299            publisher: row.get(3)?,
300            release_date: row.get(4)?,
301            genres: row.get(5)?,
302            tags,
303            series: row.get(7)?,
304            description_raw: row.get(8)?,
305            description_ptbr: row.get(9)?,
306            background_image: row.get(10)?,
307            critic_score: row.get(11)?,
308            steam_review_label: row.get(12)?,
309            steam_review_count: row.get(13)?,
310            steam_review_score: row.get(14)?,
311            steam_review_updated_at: row.get(15)?,
312            esrb_rating: row.get(16)?,
313            is_adult: row.get(17).unwrap_or(false),
314            adult_tags: row.get(18)?,
315            external_links,
316            median_playtime: row.get(20)?,
317            estimated_playtime: row.get(21)?,
318        })
319    })?;
320
321    if let Some(row) = rows.next() {
322        Ok(Some(row?))
323    } else {
324        Ok(None)
325    }
326}
327
328/// Alterna o status de favorito de um jogo.
329///
330/// Inverte o valor booleano do campo 'favorite' usando NOT lógico.
331/// Se era favorito, deixa de ser; se não era, passa a ser.
332///
333/// **Nota:** Esta operação é idempotente e não retorna erro se o ‘ID’ não existir.
334#[tauri::command]
335pub fn toggle_favorite(state: State<AppState>, id: String) -> Result<(), AppError> {
336    let conn = state.library_db.lock()?;
337
338    conn.execute(
339        "UPDATE games SET favorite = NOT favorite WHERE id = ?1",
340        params![id],
341    )?;
342
343    Ok(())
344}
345
346/// Define o status de um jogo na biblioteca.
347///
348/// Altera o campo 'status' para a condição fornecida para o jogo.
349/// Não há validação do valor; espera-se que o frontend envie valores válidos.
350/// A lista de status possíveis inclui "completed", "playing", "backlog" e "abandoned".
351#[tauri::command]
352pub fn set_game_status(state: State<AppState>, id: String, status: String) -> Result<(), AppError> {
353    let conn = state.library_db.lock()?;
354    conn.execute(
355        "UPDATE games SET status = ?1 WHERE id = ?2",
356        params![status, id],
357    )?;
358    Ok(())
359}
360
361/// Define a avaliação pessoal de um jogo.
362///
363/// Atualiza o campo 'user_rating' com o valor fornecido.
364/// Aceita valores de 0 a 5, onde 0 remove a avaliação (define como NULL).
365#[tauri::command]
366pub fn set_game_rating(state: State<AppState>, id: String, rating: i32) -> Result<(), AppError> {
367    // Validação rápida
368    if !(0..=5).contains(&rating) {
369        return Err(AppError::ValidationError("Rating inválido".to_string()));
370    }
371
372    let conn = state.library_db.lock()?;
373
374    // Se rating for 0, remove a avaliação (NULL)
375    let val = if rating == 0 { None } else { Some(rating) };
376
377    conn.execute(
378        "UPDATE games SET user_rating = ?1 WHERE id = ?2",
379        params![val, id],
380    )?;
381    Ok(())
382}
383
384/// Remove permanentemente um jogo da biblioteca.
385///
386/// **Nota:** Esta ação é irreversível e exclui todos os dados relacionados ao jogo.
387#[tauri::command]
388pub fn delete_game(state: State<AppState>, id: String) -> Result<(), AppError> {
389    let conn = state.library_db.lock()?;
390
391    conn.execute("DELETE FROM games WHERE id = ?1", params![id])?;
392
393    Ok(())
394}
395
396/// Atualiza detalhes adicionais de um jogo na biblioteca.
397///
398/// Insere ou atualiza os campos na tabela 'game_details' conforme o ID do jogo.
399/// Se os detalhes já existirem, realiza um UPDATE; caso contrário, faz um INSERT.
400/// Aceita os campos: descrição (traduzido), desenvolvedor, publicadora e data de lançamento.
401#[tauri::command]
402pub fn update_game_details(
403    state: State<AppState>,
404    payload: UpdateGameDetailsInput,
405) -> Result<(), AppError> {
406    let conn = state.library_db.lock().map_err(|_| AppError::MutexError)?;
407
408    // Verifica o estado atual do jogo no banco
409    let current_state: Option<Option<String>> = conn
410        .query_row(
411            "SELECT description_ptbr FROM game_details WHERE game_id = ?1",
412            params![payload.id],
413            |row| row.get(0),
414        )
415        .optional()?; // O '?' converte erro de SQL para AppError
416
417    match current_state {
418        // CASO 1: Registro existe
419        Some(description_ptbr_atual) => {
420            // VALIDAÇÃO: Se a descrição PT-BR for nula, impede a edição
421            if description_ptbr_atual.is_none() {
422                return Err(AppError::ValidationError(
423                    "A descrição precisa ser traduzida (ou gerada) antes de ser editada manualmente.".to_string()
424                ));
425            }
426            conn.execute(
427                "UPDATE game_details SET
428                    description_ptbr = ?1,
429                    developer = ?2,
430                    publisher = ?3,
431                    release_date = ?4
432                 WHERE game_id = ?5",
433                params![
434                    payload.description,
435                    payload.developer,
436                    payload.publisher,
437                    payload.released,
438                    payload.id
439                ],
440            )?;
441        }
442
443        // CASO 2: detalhes do jogo não existe (Novo Jogo Manual)
444        None => {
445            conn.execute(
446                "INSERT INTO game_details (game_id, description_ptbr, developer, publisher, release_date)
447                 VALUES (?1, ?2, ?3, ?4, ?5)",
448                params![
449                    payload.id,
450                    payload.description, // Define a primeira versão como "traduzida"
451                    payload.developer,
452                    payload.publisher,
453                    payload.released
454                ],
455            )?;
456        }
457    }
458
459    Ok(())
460}