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