use crate::fetcher::steam::common::{format_count, header_image_url, players_word, store_url};
use crate::fetcher::thumbnails;
use crate::payload::{
BadgeData, Bar, BarsData, Body, EntriesData, Entry, ImageData, ImageLinkedItem,
ImageLinkedListData, LinkedLine, LinkedTextBlockData, MarkdownTextBlockData, NumberSeriesData,
RatioData, Status, TextBlockData, TextData,
};
#[derive(Debug, Clone)]
pub struct GameRow {
pub rank: usize,
pub appid: u32,
pub name: String,
pub value: u64,
pub value_label: String,
}
impl GameRow {
pub fn store_url(&self) -> String {
store_url(self.appid)
}
pub fn header_image(&self) -> String {
header_image_url(self.appid)
}
}
pub fn headline(rows: &[GameRow], empty_label: &str) -> String {
match rows.first() {
Some(top) => format!("#{} {} ({})", top.rank, top.name, top.value_label),
None => empty_label.into(),
}
}
pub fn text_body(rows: &[GameRow], empty_label: &str) -> Body {
Body::Text(TextData {
value: headline(rows, empty_label),
})
}
pub fn text_block_body(rows: &[GameRow], empty_label: &str) -> Body {
Body::TextBlock(TextBlockData {
lines: lines(rows, empty_label),
})
}
fn lines(rows: &[GameRow], empty_label: &str) -> Vec<String> {
if rows.is_empty() {
return vec![empty_label.into()];
}
rows.iter()
.map(|r| format!("#{} {} {}", r.rank, r.name, r.value_label))
.collect()
}
pub fn markdown_body(rows: &[GameRow], empty_label: &str) -> Body {
let value = if rows.is_empty() {
format!("_{empty_label}_")
} else {
rows.iter()
.map(|r| format!("- **#{} {}** — {}", r.rank, r.name, r.value_label))
.collect::<Vec<_>>()
.join("\n")
};
Body::MarkdownTextBlock(MarkdownTextBlockData { value })
}
pub fn linked_text_body(rows: &[GameRow], empty_label: &str) -> Body {
let items: Vec<LinkedLine> = if rows.is_empty() {
vec![LinkedLine {
text: empty_label.into(),
url: None,
}]
} else {
rows.iter()
.map(|r| LinkedLine {
text: format!("#{} {} {}", r.rank, r.name, r.value_label),
url: Some(r.store_url()),
})
.collect()
};
Body::LinkedTextBlock(LinkedTextBlockData { items })
}
pub async fn image_linked_body(rows: &[GameRow]) -> Body {
let urls: Vec<Option<String>> = rows.iter().map(|r| Some(r.header_image())).collect();
let paths = thumbnails::download_many(&urls).await;
let items = rows
.iter()
.zip(paths)
.map(|(r, path)| ImageLinkedItem {
title: format!("#{} {}", r.rank, r.name),
url: Some(r.store_url()),
thumbnail_path: path.map(|p| p.to_string_lossy().into_owned()),
subtitle: Some(r.value_label.clone()),
})
.collect();
Body::ImageLinkedList(ImageLinkedListData { items })
}
pub fn entries_body(rows: &[GameRow], empty_label: &str) -> Body {
let items: Vec<Entry> = if rows.is_empty() {
vec![Entry {
key: "—".into(),
value: Some(empty_label.into()),
status: None,
}]
} else {
rows.iter()
.map(|r| Entry {
key: format!("#{} {}", r.rank, r.name),
value: Some(r.value_label.clone()),
status: None,
})
.collect()
};
Body::Entries(EntriesData { items })
}
pub fn ratio_body(rows: &[GameRow]) -> Body {
let total: u64 = rows.iter().map(|r| r.value).sum();
let (value, label, denominator) = match rows.first() {
Some(top) if total > 0 => (
top.value as f64 / total as f64,
Some(top.name.clone()),
Some(total),
),
_ => (0.0, None, None),
};
Body::Ratio(RatioData {
value,
label,
denominator,
})
}
pub fn number_series_body(rows: &[GameRow]) -> Body {
Body::NumberSeries(NumberSeriesData {
values: rows.iter().map(|r| r.value).collect(),
})
}
pub fn bars_body(rows: &[GameRow]) -> Body {
Body::Bars(BarsData {
bars: rows
.iter()
.map(|r| Bar {
label: r.name.clone(),
value: r.value,
value_label: Some(r.value_label.clone()),
})
.collect(),
})
}
pub async fn image_body(rows: &[GameRow]) -> Body {
let path = match rows.first() {
Some(top) => thumbnails::download_to_cache(&top.header_image())
.await
.ok()
.flatten()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default(),
None => String::new(),
};
Body::Image(ImageData { path })
}
pub fn badge_body(rows: &[GameRow], unit_label: &str, empty_label: &str) -> Body {
let (status, label) = match rows.first() {
Some(top) => (Status::Ok, format!("#1 {} · {}", top.name, top.value_label)),
None => (Status::Warn, format!("{empty_label} ({unit_label})")),
};
Body::Badge(BadgeData { status, label })
}
pub fn players_label(n: u64) -> String {
format!("{} {}", format_count(n), players_word(n))
}
#[cfg(test)]
mod tests {
use super::*;
fn row(rank: usize, appid: u32, name: &str, value: u64, label: &str) -> GameRow {
GameRow {
rank,
appid,
name: name.into(),
value,
value_label: label.into(),
}
}
#[test]
fn store_url_and_header_image_are_appid_derived() {
let r = row(1, 730, "CS2", 0, "");
assert!(r.store_url().contains("/app/730/"));
assert!(r.header_image().contains("/730/header.jpg"));
}
#[test]
fn headline_reports_top_row_with_rank_prefix() {
let rows = vec![
row(1, 730, "CS2", 60, "60h"),
row(2, 570, "Dota 2", 30, "30h"),
];
assert_eq!(headline(&rows, "empty"), "#1 CS2 (60h)");
}
#[test]
fn headline_falls_back_to_empty_label() {
assert_eq!(headline(&[], "quiet"), "quiet");
}
#[test]
fn linked_text_body_carries_store_url_per_row() {
let rows = vec![row(1, 730, "CS2", 60, "60h")];
let Body::LinkedTextBlock(b) = linked_text_body(&rows, "empty") else {
panic!("expected linked_text_block");
};
assert_eq!(b.items.len(), 1);
assert!(b.items[0].url.as_deref().unwrap().contains("/app/730/"));
assert!(b.items[0].text.contains("CS2"));
}
#[test]
fn linked_text_body_emits_unlinked_empty_row_on_empty_input() {
let Body::LinkedTextBlock(b) = linked_text_body(&[], "empty") else {
panic!("expected linked_text_block");
};
assert_eq!(b.items.len(), 1);
assert!(b.items[0].url.is_none());
}
#[test]
fn ratio_body_is_zero_when_rows_are_empty() {
let Body::Ratio(r) = ratio_body(&[]) else {
panic!("expected ratio");
};
assert_eq!(r.value, 0.0);
assert!(r.label.is_none());
assert!(r.denominator.is_none());
}
#[test]
fn ratio_body_carries_top_share_and_total_as_denominator() {
let rows = vec![
row(1, 1, "A", 60, "60"),
row(2, 2, "B", 30, "30"),
row(3, 3, "C", 10, "10"),
];
let Body::Ratio(r) = ratio_body(&rows) else {
panic!("expected ratio");
};
assert!((r.value - 0.6).abs() < 1e-9);
assert_eq!(r.label.as_deref(), Some("A"));
assert_eq!(r.denominator, Some(100));
}
#[test]
fn ratio_body_returns_zero_when_total_is_zero() {
let rows = vec![row(1, 1, "A", 0, "0"), row(2, 2, "B", 0, "0")];
let Body::Ratio(r) = ratio_body(&rows) else {
panic!("expected ratio");
};
assert_eq!(r.value, 0.0);
assert!(r.label.is_none());
}
#[test]
fn number_series_lifts_values_in_order() {
let rows = vec![row(1, 1, "A", 30, "30"), row(2, 2, "B", 20, "20")];
let Body::NumberSeries(s) = number_series_body(&rows) else {
panic!("expected number_series");
};
assert_eq!(s.values, vec![30, 20]);
}
#[test]
fn bars_body_uses_name_as_label_and_value_as_height() {
let rows = vec![row(1, 1, "CS2", 60, "60h")];
let Body::Bars(b) = bars_body(&rows) else {
panic!("expected bars");
};
assert_eq!(b.bars[0].label, "CS2");
assert_eq!(b.bars[0].value, 60);
}
#[test]
fn entries_body_uses_value_label_column() {
let rows = vec![row(1, 1, "CS2", 60, "60h")];
let Body::Entries(e) = entries_body(&rows, "empty") else {
panic!("expected entries");
};
assert_eq!(e.items[0].key, "#1 CS2");
assert_eq!(e.items[0].value.as_deref(), Some("60h"));
}
#[test]
fn badge_body_promotes_top_row_and_warns_on_empty() {
let rows = vec![row(1, 1, "CS2", 60, "60h")];
let Body::Badge(b) = badge_body(&rows, "this week", "no playtime") else {
panic!("expected badge");
};
assert_eq!(b.status, Status::Ok);
assert!(b.label.contains("CS2"));
assert!(b.label.contains("60h"));
let Body::Badge(empty) = badge_body(&[], "this week", "no playtime") else {
panic!("expected badge");
};
assert_eq!(empty.status, Status::Warn);
assert!(empty.label.contains("this week"));
}
#[test]
fn players_label_pluralises_only_for_non_singular() {
assert_eq!(players_label(0), "0 players");
assert_eq!(players_label(1), "1 player");
assert_eq!(players_label(1500), "1.5k players");
}
#[test]
fn markdown_body_italicises_empty_label() {
let Body::MarkdownTextBlock(m) = markdown_body(&[], "quiet") else {
panic!("expected markdown");
};
assert_eq!(m.value, "_quiet_");
}
#[test]
fn text_block_body_emits_single_empty_label_line_on_empty_input() {
let Body::TextBlock(b) = text_block_body(&[], "no games yet") else {
panic!("expected text_block");
};
assert_eq!(b.lines, vec!["no games yet".to_string()]);
}
#[test]
fn text_block_body_lists_one_line_per_row() {
let rows = vec![
row(1, 730, "CS2", 60, "60h"),
row(2, 570, "Dota 2", 30, "30h"),
];
let Body::TextBlock(b) = text_block_body(&rows, "empty") else {
panic!("expected text_block");
};
assert_eq!(b.lines.len(), 2);
assert_eq!(b.lines[0], "#1 CS2 60h");
}
#[test]
fn entries_body_emits_dash_placeholder_row_on_empty_input() {
let Body::Entries(e) = entries_body(&[], "no games yet") else {
panic!("expected entries");
};
assert_eq!(e.items.len(), 1);
assert_eq!(e.items[0].key, "—");
assert_eq!(e.items[0].value.as_deref(), Some("no games yet"));
}
fn thumbnail_hash(url: &str) -> String {
use sha2::{Digest, Sha256};
Sha256::digest(url.as_bytes())
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
struct SplashHomeGuard(Option<String>);
impl SplashHomeGuard {
fn set(path: &std::path::Path) -> Self {
let previous = std::env::var("SPLASHBOARD_HOME").ok();
unsafe { std::env::set_var("SPLASHBOARD_HOME", path) };
Self(previous)
}
}
impl Drop for SplashHomeGuard {
fn drop(&mut self) {
unsafe {
match self.0.take() {
Some(value) => std::env::set_var("SPLASHBOARD_HOME", value),
None => std::env::remove_var("SPLASHBOARD_HOME"),
}
}
}
}
fn block_on<T>(future: impl std::future::Future<Output = T>) -> T {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(future)
}
#[test]
fn image_linked_body_resolves_cached_thumbnails_without_network() {
let _lock = crate::paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
let _home = SplashHomeGuard::set(dir.path());
let rows = vec![
row(1, 730, "CS2", 60, "60h"),
row(2, 570, "Dota 2", 30, "30h"),
];
let thumbs = dir.path().join("cache").join("thumbnails");
std::fs::create_dir_all(&thumbs).unwrap();
for r in &rows {
let file = thumbs.join(format!("{}.png", thumbnail_hash(&r.header_image())));
std::fs::write(&file, b"\x89PNG\r\n\x1a\n").unwrap();
}
let body = block_on(image_linked_body(&rows));
let Body::ImageLinkedList(d) = body else {
panic!("expected image_linked_list");
};
assert_eq!(d.items.len(), 2);
assert_eq!(d.items[0].title, "#1 CS2");
assert_eq!(
d.items[0].url.as_deref(),
Some(rows[0].store_url().as_str())
);
assert_eq!(d.items[0].subtitle.as_deref(), Some("60h"));
assert!(
d.items[0]
.thumbnail_path
.as_deref()
.is_some_and(|p| p.ends_with(".png"))
);
}
#[test]
fn image_body_resolves_top_cached_thumbnail_without_network() {
let _lock = crate::paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
let _home = SplashHomeGuard::set(dir.path());
let rows = vec![
row(1, 730, "CS2", 60, "60h"),
row(2, 570, "Dota 2", 30, "30h"),
];
let thumbs = dir.path().join("cache").join("thumbnails");
std::fs::create_dir_all(&thumbs).unwrap();
let file = thumbs.join(format!("{}.png", thumbnail_hash(&rows[0].header_image())));
std::fs::write(&file, b"\x89PNG\r\n\x1a\n").unwrap();
let body = block_on(image_body(&rows));
let Body::Image(img) = body else {
panic!("expected image");
};
assert!(img.path.ends_with(".png"));
}
}