use chrono::{DateTime, Utc};
use crate::fetcher::thumbnails;
use crate::payload::{
BadgeData, Bar, BarsData, Body, EntriesData, Entry, ImageLinkedItem, ImageLinkedListData,
LinkedLine, LinkedTextBlockData, MarkdownTextBlockData, Status, TextBlockData, TextData,
TimelineData, TimelineEvent,
};
use crate::render::Shape;
pub(super) const MAX_ROWS: usize = 20;
const LABEL_MAX_CHARS: usize = 40;
#[derive(Debug, Clone)]
pub(super) struct DealRow {
pub title: String,
pub image_url: Option<String>,
pub sale_price: Option<String>,
pub original_price: Option<String>,
pub discount_pct: Option<u32>,
pub store: Option<String>,
pub link: String,
pub published: Option<DateTime<Utc>>,
}
impl DealRow {
pub(super) fn label(&self) -> String {
let mut s = self.title.clone();
match (&self.sale_price, &self.original_price, self.discount_pct) {
(Some(price), Some(orig), Some(pct)) => {
s.push_str(&format!(" {price} ({pct}% off from {orig})"));
}
(Some(price), _, Some(pct)) => s.push_str(&format!(" {price} ({pct}% off)")),
(Some(price), _, None) => s.push_str(&format!(" {price}")),
(None, _, Some(pct)) => s.push_str(&format!(" {pct}% off")),
(None, _, None) => {}
}
s
}
pub(super) fn label_with_store(&self) -> String {
match &self.store {
Some(store) => format!("[{store}] {}", self.label()),
None => self.label(),
}
}
fn subtitle(&self) -> Option<String> {
let pieces: Vec<String> = [
self.store.clone(),
self.sale_price.clone(),
self.discount_pct.map(|p| format!("{p}% off")),
]
.into_iter()
.flatten()
.collect();
(!pieces.is_empty()).then(|| pieces.join(" · "))
}
}
pub(super) fn body_for_shape(rows: &[DealRow], shape: Shape) -> Option<Body> {
Some(match shape {
Shape::LinkedTextBlock => linked_text_block_body(rows),
Shape::TextBlock => text_block_body(rows),
Shape::MarkdownTextBlock => markdown_body(rows),
Shape::Text => text_body(rows),
Shape::Entries => entries_body(rows),
Shape::Bars => bars_body(rows),
Shape::Badge => badge_body(rows),
Shape::Timeline => timeline_body(rows),
_ => return None,
})
}
pub(super) fn linked_text_block_body(rows: &[DealRow]) -> Body {
Body::LinkedTextBlock(LinkedTextBlockData {
items: rows
.iter()
.map(|r| LinkedLine {
text: r.label_with_store(),
url: Some(r.link.clone()),
})
.collect(),
})
}
pub(super) fn text_block_body(rows: &[DealRow]) -> Body {
Body::TextBlock(TextBlockData {
lines: rows.iter().map(|r| r.label_with_store()).collect(),
})
}
pub(super) fn markdown_body(rows: &[DealRow]) -> Body {
Body::MarkdownTextBlock(MarkdownTextBlockData {
value: rows
.iter()
.map(|r| format!("- [{}]({})", r.label_with_store(), r.link))
.collect::<Vec<_>>()
.join("\n"),
})
}
pub(super) fn text_body(rows: &[DealRow]) -> Body {
let value = top_row(rows)
.map(|r| r.label_with_store())
.unwrap_or_default();
Body::Text(TextData { value })
}
pub(super) fn entries_body(rows: &[DealRow]) -> Body {
Body::Entries(EntriesData {
items: rows
.iter()
.map(|r| Entry {
key: truncate(&r.title, LABEL_MAX_CHARS),
value: Some(entry_value(r)),
status: Some(status_for_pct(r.discount_pct.unwrap_or(0))),
})
.collect(),
})
}
pub(super) fn bars_body(rows: &[DealRow]) -> Body {
let mut sorted: Vec<&DealRow> = rows.iter().collect();
sorted.sort_by_key(|r| std::cmp::Reverse(r.discount_pct.unwrap_or(0)));
Body::Bars(BarsData {
bars: sorted
.into_iter()
.map(|r| Bar {
label: truncate(&r.title, LABEL_MAX_CHARS),
value: r.discount_pct.unwrap_or(0) as u64,
value_label: None,
})
.collect(),
})
}
pub(super) fn badge_body(rows: &[DealRow]) -> Body {
let best = top_row(rows).and_then(|r| r.discount_pct).unwrap_or(0);
let (status, label) = match best {
100 => (Status::Ok, "free this week".to_string()),
n if n >= 50 => (Status::Ok, format!("{n}% off")),
n if n > 0 => (Status::Warn, format!("{n}% off")),
_ => (Status::Warn, "no deals".to_string()),
};
Body::Badge(BadgeData { status, label })
}
pub(super) fn timeline_body(rows: &[DealRow]) -> Body {
Body::Timeline(TimelineData {
events: rows
.iter()
.map(|r| TimelineEvent {
timestamp: r.published.map(|d| d.timestamp()).unwrap_or(0),
title: r.title.clone(),
detail: r.subtitle(),
status: Some(status_for_pct(r.discount_pct.unwrap_or(0))),
})
.collect(),
})
}
pub(super) async fn image_linked_body(rows: &[DealRow]) -> Body {
let urls: Vec<Option<String>> = rows.iter().map(|r| r.image_url.clone()).collect();
let paths = thumbnails::download_many(&urls).await;
Body::ImageLinkedList(ImageLinkedListData {
items: rows
.iter()
.zip(paths)
.map(|(r, path)| ImageLinkedItem {
title: r.title.clone(),
url: Some(r.link.clone()),
thumbnail_path: path.map(|p| p.to_string_lossy().into_owned()),
subtitle: r.subtitle(),
})
.collect(),
})
}
fn entry_value(r: &DealRow) -> String {
match (&r.sale_price, r.discount_pct) {
(Some(price), Some(pct)) => format!("{price} ({pct}% off)"),
(Some(price), None) => price.clone(),
(None, Some(pct)) => format!("{pct}% off"),
(None, None) => "—".into(),
}
}
fn top_row(rows: &[DealRow]) -> Option<&DealRow> {
rows.iter().max_by_key(|r| r.discount_pct.unwrap_or(0))
}
fn status_for_pct(pct: u32) -> Status {
if pct >= 50 { Status::Ok } else { Status::Warn }
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
s.chars()
.take(max - 1)
.chain(std::iter::once('…'))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn row(title: &str, pct: Option<u32>, store: Option<&str>) -> DealRow {
DealRow {
title: title.into(),
image_url: None,
sale_price: pct.map(|_| "$4.99".into()),
original_price: pct.map(|_| "$9.99".into()),
discount_pct: pct,
store: store.map(str::to_string),
link: "https://example.com/deal".into(),
published: None,
}
}
fn custom_row(
sale_price: Option<&str>,
original_price: Option<&str>,
discount_pct: Option<u32>,
) -> DealRow {
DealRow {
title: "Game".into(),
image_url: None,
sale_price: sale_price.map(str::to_string),
original_price: original_price.map(str::to_string),
discount_pct,
store: None,
link: "https://example.com/deal".into(),
published: None,
}
}
#[test]
fn label_omits_missing_fields() {
let bare = row("Bare", None, None);
assert_eq!(bare.label(), "Bare");
}
#[test]
fn label_with_full_pricing_includes_all_pieces() {
let r = row("Half Life", Some(50), None);
assert!(r.label().contains("Half Life"));
assert!(r.label().contains("$4.99"));
assert!(r.label().contains("50% off"));
assert!(r.label().contains("$9.99"));
}
#[test]
fn label_with_store_prefixes_when_known() {
let r = row("X", Some(40), Some("Steam"));
assert!(r.label_with_store().starts_with("[Steam]"));
}
#[test]
fn entries_body_carries_discount_status() {
let rows = vec![row("A", Some(80), Some("Steam")), row("B", Some(20), None)];
let Body::Entries(data) = entries_body(&rows) else {
panic!("expected entries");
};
assert_eq!(data.items[0].status, Some(Status::Ok));
assert_eq!(data.items[1].status, Some(Status::Warn));
}
#[test]
fn bars_body_sorts_descending_by_discount() {
let rows = vec![row("low", Some(20), None), row("high", Some(80), None)];
let Body::Bars(data) = bars_body(&rows) else {
panic!("expected bars");
};
assert_eq!(data.bars[0].label, "high");
assert_eq!(data.bars[0].value, 80);
}
#[test]
fn badge_flags_free_with_dedicated_label() {
let rows = vec![row("g", Some(100), None)];
let Body::Badge(data) = badge_body(&rows) else {
panic!("expected badge");
};
assert_eq!(data.status, Status::Ok);
assert!(data.label.contains("free"));
}
#[test]
fn badge_warns_when_only_small_discounts_present() {
let rows = vec![row("g", Some(10), None)];
let Body::Badge(data) = badge_body(&rows) else {
panic!("expected badge");
};
assert_eq!(data.status, Status::Warn);
}
#[test]
fn badge_empty_rows_reports_no_deals() {
let Body::Badge(data) = badge_body(&[]) else {
panic!("expected badge");
};
assert!(data.label.contains("no deals"));
}
#[test]
fn text_body_picks_largest_discount_as_headline() {
let rows = vec![row("small", Some(10), None), row("big", Some(70), None)];
let Body::Text(data) = text_body(&rows) else {
panic!("expected text");
};
assert!(data.value.contains("big"));
}
#[test]
fn timeline_uses_publish_timestamp() {
let mut r = row("g", Some(50), Some("Epic"));
r.published = Some(chrono::TimeZone::with_ymd_and_hms(&Utc, 2026, 5, 1, 12, 0, 0).unwrap());
let Body::Timeline(data) = timeline_body(&[r]) else {
panic!("expected timeline");
};
assert!(data.events[0].timestamp > 0);
assert!(data.events[0].detail.as_ref().unwrap().contains("Epic"));
}
#[test]
fn markdown_body_emits_bullet_list_with_links() {
let r = row("g", Some(60), None);
let Body::MarkdownTextBlock(data) = markdown_body(&[r]) else {
panic!("expected markdown");
};
assert!(data.value.starts_with("- ["));
assert!(data.value.contains("(https://example.com/deal)"));
}
#[test]
fn truncate_appends_ellipsis_when_over_limit() {
let long = "x".repeat(LABEL_MAX_CHARS + 5);
let t = truncate(&long, LABEL_MAX_CHARS);
assert_eq!(t.chars().count(), LABEL_MAX_CHARS);
assert!(t.ends_with('…'));
}
#[test]
fn body_for_shape_returns_none_for_unsupported_shapes() {
assert!(body_for_shape(&[], Shape::Heatmap).is_none());
assert!(body_for_shape(&[], Shape::Calendar).is_none());
assert!(body_for_shape(&[], Shape::Ratio).is_none());
}
#[test]
fn label_drops_original_price_when_absent() {
let r = custom_row(Some("$4.99"), None, Some(50));
assert_eq!(r.label(), "Game $4.99 (50% off)");
}
#[test]
fn label_shows_bare_price_when_no_discount_known() {
let r = custom_row(Some("$4.99"), None, None);
assert_eq!(r.label(), "Game $4.99");
}
#[test]
fn label_shows_discount_only_when_no_price_known() {
let r = custom_row(None, None, Some(30));
assert_eq!(r.label(), "Game 30% off");
}
#[test]
fn body_for_shape_dispatches_each_supported_variant() {
let rows = vec![row("A", Some(60), Some("Steam"))];
for (shape, ok) in [
(
Shape::LinkedTextBlock,
matches!(
body_for_shape(&rows, Shape::LinkedTextBlock),
Some(Body::LinkedTextBlock(_))
),
),
(
Shape::TextBlock,
matches!(
body_for_shape(&rows, Shape::TextBlock),
Some(Body::TextBlock(_))
),
),
(
Shape::MarkdownTextBlock,
matches!(
body_for_shape(&rows, Shape::MarkdownTextBlock),
Some(Body::MarkdownTextBlock(_))
),
),
(
Shape::Text,
matches!(body_for_shape(&rows, Shape::Text), Some(Body::Text(_))),
),
(
Shape::Entries,
matches!(
body_for_shape(&rows, Shape::Entries),
Some(Body::Entries(_))
),
),
(
Shape::Bars,
matches!(body_for_shape(&rows, Shape::Bars), Some(Body::Bars(_))),
),
(
Shape::Badge,
matches!(body_for_shape(&rows, Shape::Badge), Some(Body::Badge(_))),
),
(
Shape::Timeline,
matches!(
body_for_shape(&rows, Shape::Timeline),
Some(Body::Timeline(_))
),
),
] {
assert!(ok, "body_for_shape mismatch for {shape:?}");
}
}
#[test]
fn linked_text_block_carries_link_per_row() {
let rows = vec![row("A", Some(20), Some("Steam"))];
let Body::LinkedTextBlock(data) = linked_text_block_body(&rows) else {
panic!("expected linked text block");
};
assert_eq!(
data.items[0].url.as_deref(),
Some("https://example.com/deal")
);
assert!(data.items[0].text.starts_with("[Steam]"));
}
#[test]
fn text_block_body_lists_one_line_per_row() {
let rows = vec![row("A", Some(20), None), row("B", None, None)];
let Body::TextBlock(data) = text_block_body(&rows) else {
panic!("expected text block");
};
assert_eq!(data.lines.len(), 2);
assert_eq!(data.lines[1], "B");
}
#[test]
fn entries_value_covers_all_price_discount_combinations() {
let rows = vec![
custom_row(Some("$4.99"), None, Some(50)),
custom_row(Some("$4.99"), None, None),
custom_row(None, None, Some(50)),
custom_row(None, None, None),
];
let Body::Entries(data) = entries_body(&rows) else {
panic!("expected entries");
};
assert_eq!(data.items[0].value.as_deref(), Some("$4.99 (50% off)"));
assert_eq!(data.items[1].value.as_deref(), Some("$4.99"));
assert_eq!(data.items[2].value.as_deref(), Some("50% off"));
assert_eq!(data.items[3].value.as_deref(), Some("—"));
}
#[tokio::test]
async fn image_linked_body_keeps_rows_without_thumbnails() {
let rows = vec![row("A", Some(60), Some("Steam")), row("B", Some(10), None)];
let Body::ImageLinkedList(data) = image_linked_body(&rows).await else {
panic!("expected image linked list");
};
assert_eq!(data.items.len(), 2);
assert!(data.items[0].thumbnail_path.is_none());
assert_eq!(
data.items[0].url.as_deref(),
Some("https://example.com/deal")
);
assert!(data.items[0].subtitle.as_ref().unwrap().contains("Steam"));
}
}