use serde::{Deserialize, Serialize};
use std::{cmp::Ordering, collections::HashMap, fs, path::PathBuf, sync::OnceLock};
const BUNDLED_GAMES_JSON: &str = include_str!("../../../assets/games.json");
static REGISTRY: OnceLock<GameRegistry> = OnceLock::new();
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct Game {
pub slug: String,
pub name: String,
pub steam_appid: Option<u32>,
pub icon_url: Option<String>,
pub cover_url: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
}
impl Game {
pub fn new(name: impl Into<String>, steam_appid: Option<u32>) -> Self {
let name = name.into();
let slug = Self::slugify(&name);
Self {
slug,
name,
steam_appid,
icon_url: None,
cover_url: None,
tags: Vec::new(),
}
}
fn slugify(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut last_dash = true;
for c in name.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
last_dash = false;
} else if !last_dash {
out.push('_');
last_dash = true;
}
}
if out.ends_with('_') {
out.pop();
}
out
}
pub fn calculate_similarity_score_with(&self, other_game: &Self) -> f32 {
if self.tags.is_empty() || other_game.tags.is_empty() {
return 0.0;
}
let num_of_common = self
.tags
.iter()
.filter(|t| other_game.tags.contains(t))
.count();
let union = self.tags.len() + other_game.tags.len() - num_of_common;
if union == 0 {
return 0.0;
}
num_of_common as f32 / union as f32
}
pub fn find_similar(&self, limit: usize) -> Vec<(Self, f32)> {
let mut scores: Vec<(Self, f32)> = GameRegistry::global()
.all()
.iter()
.filter(|game| game.slug != self.slug)
.map(|game| (game.clone(), self.calculate_similarity_score_with(game)))
.filter(|&(_, score)| score > 0.0)
.collect();
scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal));
scores.truncate(limit);
scores
}
}
#[derive(Deserialize)]
struct RawGameEntry {
slug: String,
name: String,
#[serde(default)]
steam_appid: Option<u32>,
#[serde(default)]
icon_url: Option<String>,
#[serde(default)]
cover_url: Option<String>,
#[serde(default)]
tags: Vec<String>,
exes: Vec<String>,
}
#[derive(Default, Deserialize)]
struct UserGamesConfig {
#[serde(default)]
games: Vec<RawGameEntry>,
}
pub struct GameRegistry {
by_slug: HashMap<String, Game>,
by_exe: HashMap<String, Game>,
all: Vec<Game>,
}
impl GameRegistry {
pub fn global() -> &'static Self {
REGISTRY.get_or_init(Self::load)
}
fn load() -> Self {
let mut by_slug = HashMap::new();
let mut by_exe = HashMap::new();
let mut all = Vec::new();
if let Ok(entries) = serde_json::from_str::<Vec<RawGameEntry>>(BUNDLED_GAMES_JSON) {
for entry in entries {
let game = Game {
slug: entry.slug.clone(),
name: entry.name,
steam_appid: entry.steam_appid,
icon_url: entry.icon_url,
cover_url: entry.cover_url,
tags: entry.tags,
};
for exe in entry.exes {
by_exe.insert(normalize_exe(&exe), game.clone());
}
by_slug.insert(entry.slug, game.clone());
all.push(game);
}
}
if let Some(home) = std::env::var_os("HOME") {
let config_path = PathBuf::from(home).join(".config/wayclip/games.toml");
if let Ok(raw) = fs::read_to_string(config_path) {
if let Ok(cfg) = toml::from_str::<UserGamesConfig>(&raw) {
for entry in cfg.games {
let game = Game {
slug: entry.slug.clone(),
name: entry.name,
steam_appid: entry.steam_appid,
icon_url: entry.icon_url,
cover_url: entry.cover_url,
tags: entry.tags,
};
for exe in entry.exes {
by_exe.insert(normalize_exe(&exe), game.clone());
}
by_slug.insert(entry.slug, game.clone());
all.push(game);
}
}
}
}
all.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
Self {
by_slug,
by_exe,
all,
}
}
pub fn by_exe(&self, exe: &str) -> Option<Game> {
self.by_exe.get(&normalize_exe(exe)).cloned()
}
pub fn by_slug(&self, slug: &str) -> Option<Game> {
self.by_slug.get(slug).cloned()
}
pub fn all(&self) -> &[Game] {
&self.all
}
pub fn search(&self, query: &str, limit: usize) -> Vec<Game> {
let q = query.trim().to_lowercase();
if q.is_empty() {
return self.all.iter().take(limit).cloned().collect();
}
self.all
.iter()
.filter(|g| g.name.to_lowercase().contains(&q) || g.slug.contains(&q))
.take(limit)
.cloned()
.collect()
}
}
fn normalize_exe(raw: &str) -> String {
let mut s = raw.trim().to_ascii_lowercase();
if let Some(stripped) = s.strip_suffix(".exe") {
s = stripped.to_string();
}
if let Some(stripped) = s.strip_suffix("-bin") {
s = stripped.to_string();
}
s
}