use std::sync::OnceLock;
use async_trait::async_trait;
use feed_rs::model::Entry;
use regex::Regex;
use serde::Deserialize;
use url::Url;
use super::common::{self, DealRow, MAX_ROWS};
use crate::fetcher::feed;
use crate::fetcher::github::common::{cache_key, parse_options, payload};
use crate::fetcher::{FetchContext, FetchError, Fetcher, Safety};
use crate::options::OptionSchema;
use crate::payload::{Body, Payload};
use crate::render::Shape;
use crate::samples;
const FEED_URL: &str = "https://store.steampowered.com/feeds/daily_deals.xml";
const NAME: &str = "deal_steam_daily";
const STORE: &str = "Steam";
const DEFAULT_COUNT: u32 = 10;
const MIN_COUNT: u32 = 1;
const SHAPES: &[Shape] = &[
Shape::LinkedTextBlock,
Shape::TextBlock,
Shape::MarkdownTextBlock,
Shape::Text,
Shape::Entries,
Shape::Bars,
Shape::ImageLinkedList,
Shape::Badge,
Shape::Timeline,
];
const OPTION_SCHEMAS: &[OptionSchema] = &[OptionSchema {
name: "count",
type_hint: "integer (1..=20)",
required: false,
default: Some("10"),
description: "Maximum number of deals to display.",
}];
pub struct SteamDailyFetcher;
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct Options {
#[serde(default)]
count: Option<u32>,
}
#[async_trait]
impl Fetcher for SteamDailyFetcher {
fn name(&self) -> &str {
NAME
}
fn safety(&self) -> Safety {
Safety::Safe
}
fn description(&self) -> &'static str {
"Steam's official daily deals RSS feed — currently-running individual game discounts curated by Valve. Each entry parses into `[Steam] <Game> <N>% off`. Complements `deal_games` (CheapShark) which spans every store; this one mirrors Steam's storefront priority order."
}
fn refresh_interval(&self) -> u64 {
60 * 60
}
fn shapes(&self) -> &[Shape] {
SHAPES
}
fn option_schemas(&self) -> &[OptionSchema] {
OPTION_SCHEMAS
}
fn cache_key(&self, ctx: &FetchContext) -> String {
let extra = ctx
.options
.as_ref()
.and_then(|v| toml::to_string(v).ok())
.unwrap_or_default();
cache_key(NAME, ctx, &extra)
}
fn sample_body(&self, shape: Shape) -> Option<Body> {
sample_body_for(shape)
}
async fn fetch(&self, ctx: &FetchContext) -> Result<Payload, FetchError> {
let opts: Options = parse_options(ctx.options.as_ref()).map_err(FetchError::Failed)?;
let count = opts
.count
.unwrap_or(DEFAULT_COUNT)
.clamp(MIN_COUNT, MAX_ROWS as u32) as usize;
let url = Url::parse(FEED_URL)
.map_err(|e| FetchError::Failed(format!("deal_steam_daily: bad feed url: {e}")))?;
let bytes = feed::fetch_bytes(&url, NAME).await?;
let parsed = feed::parse_feed(&bytes, NAME)?;
let rows: Vec<DealRow> = parsed
.entries
.iter()
.take(count)
.map(entry_to_row)
.collect();
let shape = ctx.shape.unwrap_or(Shape::LinkedTextBlock);
let body = match shape {
Shape::ImageLinkedList => common::image_linked_body(&rows).await,
other => common::body_for_shape(&rows, other)
.unwrap_or_else(|| common::linked_text_block_body(&rows)),
};
Ok(payload(body))
}
}
fn entry_to_row(entry: &Entry) -> DealRow {
let raw_title = entry
.title
.as_ref()
.map(|t| feed::collapse_whitespace(&t.content))
.unwrap_or_default();
let (title, discount_pct) = parse_title(&raw_title);
let link =
feed::link_for(entry).unwrap_or_else(|| String::from("https://store.steampowered.com"));
DealRow {
title: if title.is_empty() {
"(unnamed deal)".into()
} else {
title
},
image_url: feed::thumbnail_url_for(entry),
sale_price: None,
original_price: None,
discount_pct,
store: Some(STORE.into()),
link,
published: entry.published.or(entry.updated),
}
}
fn parse_title(raw: &str) -> (String, Option<u32>) {
static TITLE_RE: OnceLock<Regex> = OnceLock::new();
let re = TITLE_RE.get_or_init(|| {
Regex::new(
r"^(?P<prefix>[A-Za-z][A-Za-z ]+?)\s*-\s*(?P<title>.+?)(?:,\s*(?P<pct>\d{1,3})% Off!?)?$",
)
.unwrap()
});
match re.captures(raw) {
Some(c) => {
let title = c["title"].trim().to_string();
let pct = c.name("pct").and_then(|m| m.as_str().parse::<u32>().ok());
(title, pct.map(|p| p.min(100)))
}
None => (raw.to_string(), None),
}
}
fn sample_body_for(shape: Shape) -> Option<Body> {
Some(match shape {
Shape::LinkedTextBlock => samples::linked_text_block(&[
(
"[Steam] Portal 2 75% off",
Some("https://store.steampowered.com/app/620/Portal_2/"),
),
(
"[Steam] Hades 50% off",
Some("https://store.steampowered.com/app/1145360/Hades/"),
),
(
"[Steam] Stardew Valley 40% off",
Some("https://store.steampowered.com/app/413150/Stardew_Valley/"),
),
]),
Shape::TextBlock => samples::text_block(&[
"[Steam] Portal 2 75% off",
"[Steam] Hades 50% off",
"[Steam] Stardew Valley 40% off",
]),
Shape::MarkdownTextBlock => samples::markdown(
"- [[Steam] Portal 2 75% off](https://store.steampowered.com/app/620/Portal_2/)\n- [[Steam] Hades 50% off](https://store.steampowered.com/app/1145360/Hades/)",
),
Shape::Text => samples::text("[Steam] Portal 2 75% off"),
Shape::Entries => samples::entries(&[
("Portal 2", "75% off"),
("Hades", "50% off"),
("Stardew Valley", "40% off"),
]),
Shape::Bars => samples::bars(&[("Portal 2", 75), ("Hades", 50), ("Stardew Valley", 40)]),
Shape::Badge => samples::badge(crate::payload::Status::Ok, "75% off"),
Shape::Timeline => samples::timeline(&[
(1_745_625_600, "Portal 2", Some("Steam · 75% off")),
(1_745_539_200, "Hades", Some("Steam · 50% off")),
]),
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
use feed_rs::model::{Link, Text};
fn text(content: &str) -> Text {
Text {
content_type: "text/plain".parse().unwrap(),
src: None,
content: content.into(),
}
}
fn link(href: &str) -> Link {
Link {
href: href.into(),
target: None,
rel: None,
media_type: None,
href_lang: None,
title: None,
length: None,
}
}
#[test]
fn parse_title_extracts_daily_deal_discount() {
let (title, pct) = parse_title("Daily Deal - The Beast Inside, 25% Off");
assert_eq!(title, "The Beast Inside");
assert_eq!(pct, Some(25));
}
#[test]
fn parse_title_handles_weekend_deal_with_exclamation() {
let (title, pct) = parse_title("Weekend Deal - Portal 2, 75% Off!");
assert_eq!(title, "Portal 2");
assert_eq!(pct, Some(75));
}
#[test]
fn parse_title_handles_midweek_madness_three_word_prefix() {
let (title, pct) = parse_title("Midweek Madness - Game Of The Year, 50% Off");
assert_eq!(title, "Game Of The Year");
assert_eq!(pct, Some(50));
}
#[test]
fn parse_title_returns_none_pct_when_clause_absent() {
let (title, pct) = parse_title("Free Weekend - Some Multiplayer Game");
assert_eq!(title, "Some Multiplayer Game");
assert!(pct.is_none());
}
#[test]
fn parse_title_clamps_implausible_percentages() {
let (_title, pct) = parse_title("Daily Deal - Bug, 250% Off");
assert_eq!(pct, Some(100));
}
#[test]
fn parse_title_falls_back_to_raw_when_pattern_misses() {
let (title, pct) = parse_title("just a random string");
assert_eq!(title, "just a random string");
assert!(pct.is_none());
}
#[test]
fn options_reject_unknown_keys() {
let raw: toml::Value = toml::from_str("count = 5\nbogus = 1").unwrap();
assert!(raw.try_into::<Options>().is_err());
}
#[test]
fn fetcher_exposes_safety_safe_and_steam_store_attribution() {
let f = SteamDailyFetcher;
assert_eq!(f.name(), NAME);
assert_eq!(f.safety(), Safety::Safe);
assert_eq!(f.default_shape(), Shape::LinkedTextBlock);
}
#[test]
fn sample_body_covers_every_supported_shape() {
let f = SteamDailyFetcher;
for shape in [
Shape::LinkedTextBlock,
Shape::TextBlock,
Shape::MarkdownTextBlock,
Shape::Text,
Shape::Entries,
Shape::Bars,
Shape::Badge,
Shape::Timeline,
] {
assert!(
f.sample_body(shape).is_some(),
"sample missing for {shape:?}"
);
}
assert!(f.sample_body(Shape::ImageLinkedList).is_none());
assert!(f.sample_body(Shape::Heatmap).is_none());
}
#[test]
fn entry_to_row_parses_steam_title_link_and_store() {
let entry = Entry {
title: Some(text("Daily Deal - Portal 2, 75% Off")),
links: vec![link("https://store.steampowered.com/app/620/Portal_2/")],
published: Some(Utc.with_ymd_and_hms(2026, 4, 26, 12, 0, 0).unwrap()),
..Default::default()
};
let row = entry_to_row(&entry);
assert_eq!(row.title, "Portal 2");
assert_eq!(row.discount_pct, Some(75));
assert_eq!(row.store.as_deref(), Some(STORE));
assert_eq!(row.link, "https://store.steampowered.com/app/620/Portal_2/");
assert_eq!(row.published, entry.published);
assert!(row.image_url.is_none());
assert!(row.sale_price.is_none());
assert!(row.original_price.is_none());
}
#[test]
fn entry_to_row_names_titleless_entries_unnamed_deal() {
let row = entry_to_row(&Entry::default());
assert_eq!(row.title, "(unnamed deal)");
assert!(row.discount_pct.is_none());
}
#[test]
fn entry_to_row_falls_back_to_steam_store_url_when_link_missing() {
let entry = Entry {
title: Some(text("Weekend Deal - Hades, 50% Off")),
..Default::default()
};
let row = entry_to_row(&entry);
assert_eq!(row.link, "https://store.steampowered.com");
assert_eq!(row.title, "Hades");
assert_eq!(row.discount_pct, Some(50));
}
#[test]
fn entry_to_row_uses_updated_timestamp_when_published_absent() {
let updated = Utc.with_ymd_and_hms(2026, 5, 1, 9, 0, 0).unwrap();
let entry = Entry {
title: Some(text("Midweek Madness - Celeste, 60% Off")),
updated: Some(updated),
..Default::default()
};
let row = entry_to_row(&entry);
assert_eq!(row.published, Some(updated));
assert_eq!(row.title, "Celeste");
}
#[test]
fn cache_key_is_name_prefixed_and_varies_with_options() {
let f = SteamDailyFetcher;
let bare = f.cache_key(&FetchContext::default());
assert!(bare.starts_with(NAME), "got: {bare}");
let with_opts = f.cache_key(&FetchContext {
options: Some(toml::from_str("count = 5").unwrap()),
..Default::default()
});
assert!(with_opts.starts_with(NAME), "got: {with_opts}");
assert_ne!(bare, with_opts);
}
}