Skip to main content

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