Skip to main content

game_manager_lib/utils/
text.rs

1//! Utilitários para manipulação de Strings em nomes de jogos, séries e tags.
2
3// === CONSTANTS ===
4
5const EDITION_SUFFIXES: &[&str] = &[
6    "Collector's Edition",
7    "Collectors Edition",
8    "Complete Edition",
9    "Game of the Year Edition",
10    "Definitive Edition",
11    "Special Edition",
12    "Deluxe Edition",
13    "Premium Edition",
14    "Enhanced Edition",
15    "GOTY Edition",
16    "GOTY",
17    "Ultimate Edition",
18];
19
20// === FUNÇÕES ===
21
22/// Remove símbolos de marca registrada, preservando capitalização e conteúdo.
23/// Uso: limpar nome antes de persistir (import), exibição.
24pub fn strip_trademark_symbols(name: &str) -> String {
25    name.chars()
26        .filter(|c| !matches!(c, '™' | '®' | '©'))
27        .collect::<String>()
28        .split_whitespace()
29        .collect::<Vec<_>>()
30        .join(" ")
31}
32
33/// Normaliza para comparação/matching: minúsculo, sem símbolos, sem pontuação de separador.
34/// Uso: cache keys, dedup, series matching, comparação com resultado de busca externa.
35pub fn normalize_for_matching(name: &str) -> String {
36    strip_trademark_symbols(name)
37        .to_lowercase()
38        .replace([':', ',', '\'', '\u{2019}'], "")
39        .trim()
40        .to_string()
41}
42
43/// Verifica se `needle` aparece em `haystack` com fronteira de palavra nos dois lados
44/// (sem letra/dígito colado antes ou depois). Evita falsos positivos como "demo"
45/// dentro de "demon's" ou "trial" dentro de "trials".
46pub fn contains_word_boundary(haystack: &str, needle: &str) -> bool {
47    let h = haystack.as_bytes();
48    let n = needle.as_bytes();
49    if n.is_empty() || n.len() > h.len() {
50        return false;
51    }
52    for start in 0..=(h.len() - n.len()) {
53        if &h[start..start + n.len()] == n {
54            let before_ok = start == 0 || !(h[start - 1] as char).is_alphanumeric();
55            let end = start + n.len();
56            let after_ok = end == h.len() || !(h[end] as char).is_alphanumeric();
57            if before_ok && after_ok {
58                return true;
59            }
60        }
61    }
62    false
63}
64
65/// Heurística por palavra-chave para identificar nomes que provavelmente
66/// não são o jogo-base (DLC, edição especial, trilha sonora, demo, pré-venda).
67/// Não é definitivo — é um filtro barato para descartar candidatos óbvios
68/// antes de aplicar critérios mais confiáveis (match exato, dados estruturados).
69pub fn is_likely_non_base_game(name: &str) -> bool {
70    let lower = name.to_lowercase();
71    if name.trim().len() <= 2 {
72        return true;
73    }
74
75    // Palavras curtas com risco comprovado de colisão (demon's, trials) — exigem fronteira de palavra nos dois lados.
76    let boundary_keywords = ["demo", "trial"];
77    if boundary_keywords
78        .iter()
79        .any(|kw| contains_word_boundary(&lower, kw))
80    {
81        return true;
82    }
83
84    // Demais keywords: substring simples é seguro, pois não há risco de colisão com nomes de jogos conhecidos.
85    let substring_keywords = [
86        "season pass",
87        "dlc",
88        "add-on",
89        "addon",
90        "expansion",
91        "pre-order",
92        "preorder",
93        "pre order",
94        "starter pack",
95        "pack",
96        "soundtrack",
97        "artbook",
98        "art book",
99        "playtest",
100        "goodie",
101    ];
102    substring_keywords.iter().any(|kw| lower.contains(kw))
103}
104
105/// Remove sufixo de edição conhecido do final do nome, se houver.
106/// Usado como segunda tentativa de match quando o nome completo (com edição) não bate.
107pub fn strip_edition_suffix(name: &str) -> String {
108    let trimmed = name.trim();
109    for suffix in EDITION_SUFFIXES {
110        if let Some(rest) = trimmed.to_lowercase().strip_suffix(&suffix.to_lowercase()) {
111            return trimmed[..rest.len()]
112                .trim_end_matches(['-', ':', ',', ' '])
113                .trim()
114                .to_string();
115        }
116    }
117    trimmed.to_string()
118}
119
120/// Gera um slug normalizado para comparação/dedup: minúsculo, sem símbolos,
121/// palavras separadas por hífen. Não usar para exibição — preserva apenas
122/// o essencial para matching entre plataformas.
123/// Ex: "BioShock Infinite: Complete Edition" -> "bioshock-infinite-complete-edition"
124pub fn slugify(name: &str) -> String {
125    strip_trademark_symbols(name)
126        .to_lowercase()
127        .chars()
128        .map(|c| if c.is_alphanumeric() { c } else { ' ' })
129        .collect::<String>()
130        .split_whitespace()
131        .collect::<Vec<_>>()
132        .join("-")
133}